@editframe/elements 0.14.0-beta.2 → 0.15.0-beta.1

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.
@@ -1,10 +1,12 @@
1
1
  import { EFAudio } from "./EFAudio.js";
2
2
 
3
+ import { CSSStyleObserver } from "@bramus/style-observer";
3
4
  import { Task } from "@lit/task";
4
5
  import { LitElement, type PropertyValueMap, css, html } from "lit";
5
6
  import { customElement, property } from "lit/decorators.js";
6
7
  import { type Ref, createRef, ref } from "lit/directives/ref.js";
7
8
  import { EF_INTERACTIVE } from "../EF_INTERACTIVE.js";
9
+ import { EF_RENDERING } from "../EF_RENDERING.js";
8
10
  import { TWMixin } from "../gui/TWMixin.js";
9
11
  import { CrossUpdateController } from "./CrossUpdateController.js";
10
12
  import { EFTemporal } from "./EFTemporal.js";
@@ -30,6 +32,7 @@ export class EFWaveform extends EFTemporal(TWMixin(LitElement)) {
30
32
 
31
33
  canvasRef: Ref<HTMLCanvasElement> = createRef();
32
34
  private ctx: CanvasRenderingContext2D | null = null;
35
+ private styleObserver: CSSStyleObserver | null = null;
33
36
 
34
37
  private resizeObserver?: ResizeObserver;
35
38
  private mutationObserver?: MutationObserver;
@@ -42,15 +45,7 @@ export class EFWaveform extends EFTemporal(TWMixin(LitElement)) {
42
45
  type: String,
43
46
  attribute: "mode",
44
47
  })
45
- mode:
46
- | "roundBars"
47
- | "bars"
48
- | "bricks"
49
- | "equalizer"
50
- | "curve"
51
- | "line"
52
- | "pixel"
53
- | "wave" = "bars";
48
+ mode: "roundBars" | "bars" | "bricks" | "line" | "pixel" | "wave" = "bars";
54
49
 
55
50
  @property({ type: String })
56
51
  color = "currentColor";
@@ -58,14 +53,23 @@ export class EFWaveform extends EFTemporal(TWMixin(LitElement)) {
58
53
  @property({ type: String, attribute: "target", reflect: true })
59
54
  targetSelector = "";
60
55
 
56
+ @property({ type: Number, attribute: "line-width" })
57
+ lineWidth = 4;
58
+
61
59
  set target(value: string) {
62
60
  this.targetSelector = value;
63
61
  }
64
62
 
65
63
  connectedCallback() {
66
64
  super.connectedCallback();
67
- if (this.targetElement) {
68
- new CrossUpdateController(this.targetElement, this);
65
+ try {
66
+ if (this.targetElement) {
67
+ new CrossUpdateController(this.targetElement, this);
68
+ }
69
+ } catch (e) {
70
+ // TODO: determine if this is a critical error, or if we should just ignore it
71
+ // currenty evidence suggests everything still works
72
+ // no target element, no cross update controller
69
73
  }
70
74
 
71
75
  // Initialize ResizeObserver
@@ -87,6 +91,13 @@ export class EFWaveform extends EFTemporal(TWMixin(LitElement)) {
87
91
 
88
92
  // Observe attribute changes on the element
89
93
  this.mutationObserver.observe(this, { attributes: true });
94
+
95
+ if (!EF_RENDERING()) {
96
+ this.styleObserver = new CSSStyleObserver(["color"], () => {
97
+ this.frameTask.run();
98
+ });
99
+ this.styleObserver.attach(this);
100
+ }
90
101
  }
91
102
 
92
103
  disconnectedCallback() {
@@ -94,6 +105,7 @@ export class EFWaveform extends EFTemporal(TWMixin(LitElement)) {
94
105
  // Disconnect the observers when the element is removed from the DOM
95
106
  this.resizeObserver?.disconnect();
96
107
  this.mutationObserver?.disconnect();
108
+ this.styleObserver?.detach();
97
109
  }
98
110
 
99
111
  private resizeCanvas() {
@@ -107,7 +119,10 @@ export class EFWaveform extends EFTemporal(TWMixin(LitElement)) {
107
119
  const canvas = this.canvasRef.value;
108
120
  if (!canvas) return null;
109
121
 
110
- const rect = this.getBoundingClientRect();
122
+ const rect = {
123
+ width: this.offsetWidth,
124
+ height: this.offsetHeight,
125
+ };
111
126
  const dpr = window.devicePixelRatio;
112
127
 
113
128
  canvas.style.width = `${rect.width}px`;
@@ -128,20 +143,34 @@ export class EFWaveform extends EFTemporal(TWMixin(LitElement)) {
128
143
 
129
144
  protected drawBars(ctx: CanvasRenderingContext2D, frequencyData: Uint8Array) {
130
145
  const canvas = ctx.canvas;
131
- const waveWidth = canvas.width / devicePixelRatio;
132
- const waveHeight = canvas.height / devicePixelRatio;
133
- const barWidth = (waveWidth / frequencyData.length) * 0.8; // 0.8 for padding
134
- const baseline = waveHeight / 2;
146
+ const waveWidth = canvas.width;
147
+ const waveHeight = canvas.height;
148
+ const baseline = waveHeight / 4;
149
+
150
+ // Calculate bar width with padding
151
+ const totalBars = frequencyData.length;
152
+ const paddingInner = 0.5; // 50% padding between bars
153
+ const paddingOuter = 0.01; // 1% padding on edges
154
+ const availableWidth = waveWidth * (1 - 2 * paddingOuter);
155
+ const barWidth =
156
+ availableWidth / (totalBars + (totalBars - 1) * paddingInner);
135
157
 
136
158
  ctx.clearRect(0, 0, waveWidth, waveHeight);
137
159
 
160
+ // Create a single Path2D object for all bars
161
+ const path = new Path2D();
162
+
138
163
  frequencyData.forEach((value, i) => {
139
- const height = (value / 255) * (waveHeight / 2);
140
- const x = i * (waveWidth / frequencyData.length);
164
+ const normalizedValue = value / 255;
165
+ const height = normalizedValue * (waveHeight / 2);
166
+ const x = waveWidth * paddingOuter + i * (barWidth * (1 + paddingInner));
141
167
  const y = baseline - height;
142
168
 
143
- ctx.fillRect(x, y, barWidth, Math.max(height * 2, 2));
169
+ path.rect(x, y, barWidth, height * 2);
144
170
  });
171
+
172
+ // Single fill operation for all bars
173
+ ctx.fill(path);
145
174
  }
146
175
 
147
176
  protected drawBricks(
@@ -149,55 +178,23 @@ export class EFWaveform extends EFTemporal(TWMixin(LitElement)) {
149
178
  frequencyData: Uint8Array,
150
179
  ) {
151
180
  const canvas = ctx.canvas;
152
- const waveWidth = canvas.width / devicePixelRatio;
153
- const waveHeight = canvas.height / devicePixelRatio;
154
- const brickWidth = waveWidth / frequencyData.length;
155
- const brickHeightFactor = waveHeight / 255 / 2;
156
- const brickPadding = 2;
157
- const midHeight = waveHeight / 2;
158
-
181
+ const waveWidth = canvas.width;
182
+ const waveHeight = canvas.height;
159
183
  ctx.clearRect(0, 0, waveWidth, waveHeight);
184
+ const path = new Path2D();
160
185
 
161
- // Draw baseline
162
- ctx.beginPath();
163
- ctx.setLineDash([2]);
164
- ctx.lineWidth = 4;
165
- ctx.moveTo(0, midHeight);
166
- ctx.lineTo(waveWidth, midHeight);
167
- ctx.stroke();
168
- ctx.setLineDash([]); // Reset dash
169
-
170
- // Draw bricks
186
+ const columnWidth = waveWidth / frequencyData.length;
187
+ const boxSize = columnWidth * 0.9;
171
188
  frequencyData.forEach((value, i) => {
172
- const x = i * brickWidth;
173
- const height = value * brickHeightFactor * 2;
174
- const y = midHeight - height / 2;
175
-
176
- ctx.fillRect(x, y, brickWidth - brickPadding, height);
177
- });
178
- }
179
-
180
- protected drawLine(ctx: CanvasRenderingContext2D, frequencyData: Uint8Array) {
181
- const canvas = ctx.canvas;
182
- const waveWidth = canvas.width / devicePixelRatio;
183
- const waveHeight = canvas.height / devicePixelRatio;
184
-
185
- ctx.clearRect(0, 0, waveWidth, waveHeight);
186
- ctx.beginPath();
187
-
188
- frequencyData.forEach((value, i) => {
189
- const x = (i / frequencyData.length) * waveWidth;
190
- const y = (1 - value / 255) * waveHeight;
191
-
192
- if (i === 0) {
193
- ctx.moveTo(x, y);
194
- } else {
195
- ctx.lineTo(x, y);
189
+ const brickHeight = (value / 255) * waveHeight;
190
+ for (let j = 0; j <= brickHeight; j++) {
191
+ const x = columnWidth * i;
192
+ const y = waveHeight - (j * columnWidth + boxSize);
193
+ path.rect(x, y, boxSize, boxSize);
196
194
  }
197
195
  });
198
196
 
199
- ctx.lineWidth = 4;
200
- ctx.stroke();
197
+ ctx.fill(path);
201
198
  }
202
199
 
203
200
  protected drawRoundBars(
@@ -205,25 +202,35 @@ export class EFWaveform extends EFTemporal(TWMixin(LitElement)) {
205
202
  frequencyData: Uint8Array,
206
203
  ) {
207
204
  const canvas = ctx.canvas;
208
- const waveWidth = canvas.width / devicePixelRatio;
209
- const waveHeight = canvas.height / devicePixelRatio;
210
- const barWidth = (waveWidth / frequencyData.length) * 0.5; // Add padding
211
- const baseline = waveHeight / 2;
212
- const maxHeight = waveHeight / 2;
205
+ const waveWidth = canvas.width;
206
+ const waveHeight = canvas.height;
207
+ const baseline = waveHeight / 4;
208
+
209
+ // Similar padding calculation as drawBars
210
+ const totalBars = frequencyData.length;
211
+ const paddingInner = 0.5;
212
+ const paddingOuter = 0.01;
213
+ const availableWidth = waveWidth * (1 - 2 * paddingOuter);
214
+ const barWidth =
215
+ availableWidth / (totalBars + (totalBars - 1) * paddingInner);
213
216
 
214
217
  ctx.clearRect(0, 0, waveWidth, waveHeight);
215
218
 
219
+ // Create a single Path2D object for all rounded bars
220
+ const path = new Path2D();
221
+
216
222
  frequencyData.forEach((value, i) => {
217
- const height = (value / 255) * maxHeight;
218
- const x = i * (waveWidth / frequencyData.length);
223
+ const normalizedValue = value / 255;
224
+ const height = normalizedValue * (waveHeight / 2);
225
+ const x = waveWidth * paddingOuter + i * (barWidth * (1 + paddingInner));
219
226
  const y = baseline - height;
220
227
 
221
- // Draw rounded rectangle
222
- const radius = barWidth / 2;
223
- ctx.beginPath();
224
- ctx.roundRect(x, y, barWidth, Math.max(height * 2, 2), radius);
225
- ctx.fill();
228
+ // Add rounded rectangle to path
229
+ path.roundRect(x, y, barWidth, height * 2, barWidth / 2);
226
230
  });
231
+
232
+ // Single fill operation for all bars
233
+ ctx.fill(path);
227
234
  }
228
235
 
229
236
  protected drawEqualizer(
@@ -231,54 +238,60 @@ export class EFWaveform extends EFTemporal(TWMixin(LitElement)) {
231
238
  frequencyData: Uint8Array,
232
239
  ) {
233
240
  const canvas = ctx.canvas;
234
- const waveWidth = canvas.width / devicePixelRatio;
235
- const waveHeight = canvas.height / devicePixelRatio;
241
+ const waveWidth = canvas.width;
242
+ const waveHeight = canvas.height / 2;
236
243
  const barWidth = (waveWidth / frequencyData.length) * 0.8;
237
244
  const baseline = waveHeight / 2;
238
245
 
239
246
  ctx.clearRect(0, 0, waveWidth, waveHeight);
240
247
 
248
+ // Create paths for baseline and bars
249
+ const baselinePath = new Path2D();
250
+ const barsPath = new Path2D();
251
+
241
252
  // Draw baseline
242
- ctx.beginPath();
243
- ctx.lineWidth = 2;
244
- ctx.moveTo(0, baseline);
245
- ctx.lineTo(waveWidth, baseline);
246
- ctx.stroke();
253
+ baselinePath.moveTo(0, baseline);
254
+ baselinePath.lineTo(waveWidth, baseline);
247
255
 
248
256
  // Draw bars
249
257
  frequencyData.forEach((value, i) => {
250
258
  const height = (value / 255) * (waveHeight / 2);
251
259
  const x = i * (waveWidth / frequencyData.length);
252
260
  const y = baseline - height;
253
-
254
- ctx.fillRect(x, y, barWidth, Math.max(height * 2, 1));
261
+ barsPath.rect(x, y, barWidth, Math.max(height * 2, 1));
255
262
  });
263
+
264
+ // Render baseline
265
+ ctx.lineWidth = 2;
266
+ ctx.stroke(baselinePath);
267
+
268
+ // Render bars
269
+ ctx.fill(barsPath);
256
270
  }
257
271
 
258
- protected drawCurve(
259
- ctx: CanvasRenderingContext2D,
260
- frequencyData: Uint8Array,
261
- ) {
272
+ protected drawLine(ctx: CanvasRenderingContext2D, frequencyData: Uint8Array) {
262
273
  const canvas = ctx.canvas;
263
- const waveWidth = canvas.width / devicePixelRatio;
264
- const waveHeight = canvas.height / devicePixelRatio;
274
+ const waveWidth = canvas.width;
275
+ const waveHeight = canvas.height / 2;
265
276
 
266
277
  ctx.clearRect(0, 0, waveWidth, waveHeight);
267
- ctx.beginPath();
278
+
279
+ // Create a single Path2D object for the curve
280
+ const path = new Path2D();
268
281
 
269
282
  frequencyData.forEach((value, i) => {
270
283
  const x = (i / frequencyData.length) * waveWidth;
271
284
  const y = (1 - value / 255) * waveHeight;
272
285
 
273
286
  if (i === 0) {
274
- ctx.moveTo(x, y);
287
+ path.moveTo(x, y);
275
288
  } else {
276
- ctx.lineTo(x, y);
289
+ path.lineTo(x, y);
277
290
  }
278
291
  });
279
292
 
280
- ctx.lineWidth = 4;
281
- ctx.stroke();
293
+ ctx.lineWidth = this.lineWidth;
294
+ ctx.stroke(path);
282
295
  }
283
296
 
284
297
  protected drawPixel(
@@ -286,132 +299,122 @@ export class EFWaveform extends EFTemporal(TWMixin(LitElement)) {
286
299
  frequencyData: Uint8Array,
287
300
  ) {
288
301
  const canvas = ctx.canvas;
289
- const waveWidth = canvas.width / devicePixelRatio;
290
- const waveHeight = canvas.height / devicePixelRatio;
302
+ const waveWidth = canvas.width;
303
+ const waveHeight = canvas.height / 2;
291
304
  const baseline = waveHeight / 2;
292
- const barWidth = (waveWidth / frequencyData.length) * 0.97; // Account for padding
305
+ const barWidth = waveWidth / frequencyData.length;
293
306
 
294
307
  ctx.clearRect(0, 0, waveWidth, waveHeight);
295
308
 
309
+ // Create a single Path2D object for all pixels
310
+ const path = new Path2D();
311
+
296
312
  frequencyData.forEach((value, i) => {
297
313
  const x = i * (waveWidth / frequencyData.length);
298
314
  const barHeight = (value / 255) * baseline;
299
315
  const y = baseline - barHeight;
300
316
 
301
- ctx.fillRect(x, y, barWidth, barHeight * 2);
317
+ path.rect(x, y, barWidth, barHeight * 2);
302
318
  });
319
+
320
+ // Single fill operation for all pixels
321
+ ctx.fill(path);
303
322
  }
304
323
 
305
324
  protected drawWave(ctx: CanvasRenderingContext2D, frequencyData: Uint8Array) {
306
325
  const canvas = ctx.canvas;
307
- const waveWidth = canvas.width / devicePixelRatio;
308
- const waveHeight = canvas.height / devicePixelRatio;
309
- const baseline = waveHeight / 2;
310
- const barWidth = (waveWidth / frequencyData.length) * 0.97; // Account for padding
326
+ const waveWidth = canvas.width;
327
+ const waveHeight = canvas.height;
328
+ const baseline = canvas.height / 4;
311
329
 
312
330
  ctx.clearRect(0, 0, waveWidth, waveHeight);
331
+ const path = new Path2D();
332
+ path.moveTo(0, baseline);
313
333
 
314
- // Draw baseline
315
- ctx.beginPath();
316
- ctx.moveTo(0, baseline);
317
- ctx.lineTo(waveWidth, baseline);
318
- ctx.strokeStyle = this.color;
319
- ctx.lineWidth = 1;
320
- ctx.stroke();
321
-
322
- // Draw bars
334
+ // Draw top curve
323
335
  frequencyData.forEach((value, i) => {
324
- const x = i * (waveWidth / frequencyData.length);
325
- const barHeight = (value / 255) * (waveHeight / 2);
326
- const y = baseline - barHeight;
336
+ const normalizedValue = value / 255;
337
+ const x = (i / (frequencyData.length - 1)) * waveWidth;
338
+ const y = baseline - normalizedValue * (waveHeight / 2);
327
339
 
328
- ctx.fillRect(x, y, barWidth, barHeight * 2);
340
+ if (i === 0) {
341
+ path.moveTo(x, y);
342
+ } else {
343
+ const prevX = ((i - 1) / (frequencyData.length - 1)) * waveWidth;
344
+ const prevValue = (frequencyData[i - 1] ?? 0) / 255;
345
+ const prevY = baseline - prevValue * (waveHeight / 2);
346
+ const xc = (prevX + x) / 2;
347
+ const yc = (prevY + y) / 2;
348
+ path.quadraticCurveTo(prevX, prevY, xc, yc);
349
+ }
329
350
  });
351
+
352
+ // Draw bottom curve
353
+ for (let i = frequencyData.length - 1; i >= 0; i--) {
354
+ const normalizedValue = (frequencyData[i] ?? 0) / 255;
355
+ const x = (i / (frequencyData.length - 1)) * waveWidth;
356
+ const y = baseline + normalizedValue * (waveHeight / 2);
357
+
358
+ if (i === frequencyData.length - 1) {
359
+ path.lineTo(x, y);
360
+ } else {
361
+ const nextX = ((i + 1) / (frequencyData.length - 1)) * waveWidth;
362
+ const nextValue = (frequencyData[i + 1] ?? 0) / 255;
363
+ const nextY = baseline + nextValue * (waveHeight / 2);
364
+ const xc = (nextX + x) / 2;
365
+ const yc = (nextY + y) / 2;
366
+ path.quadraticCurveTo(nextX, nextY, xc, yc);
367
+ }
368
+ }
369
+
370
+ ctx.fill(path);
330
371
  }
331
372
 
332
373
  frameTask = new Task(this, {
333
374
  autoRun: EF_INTERACTIVE,
334
- args: () => [this.targetElement.audioBufferTask.status] as const,
375
+ args: () => [this.targetElement?.frequencyDataTask.status] as const,
335
376
  task: async () => {
336
- await this.targetElement.audioBufferTask.taskComplete;
337
- // Lazy initialize canvas, if we don't stash it, we re-init
338
- // every frame, which blanks the canvas, causing flicker.
377
+ if (!this.targetElement) return;
378
+ await this.targetElement.frequencyDataTask.taskComplete;
339
379
  this.ctx ||= this.initCanvas();
340
380
  const ctx = this.ctx;
341
381
  if (!ctx) return;
342
382
 
343
- if (!this.targetElement.audioBufferTask.value) return;
344
-
345
- if (this.targetElement.trimAdjustedOwnCurrentTimeMs > 0) {
346
- const FRAME_DURATION_MS = 48000 / 1000;
347
- const FRAME_SMEAR_MS = FRAME_DURATION_MS * 1;
348
- const FRAME_SMEAR_S = FRAME_SMEAR_MS / 1000;
349
-
350
- const audioContext = new OfflineAudioContext(2, 48000 / 25, 48000);
351
- const audioBufferSource = audioContext.createBufferSource();
352
- audioBufferSource.buffer =
353
- this.targetElement.audioBufferTask.value.buffer;
354
- const analyser = audioContext.createAnalyser();
355
-
356
- // Adjust FFT size for better frequency resolution
357
- analyser.fftSize = 128 * 8;
383
+ const frequencyData = this.targetElement.frequencyDataTask.value;
384
+ if (!frequencyData) return;
358
385
 
359
- audioBufferSource.connect(analyser);
360
-
361
- const startTime = Math.max(
362
- 0,
363
- (this.targetElement.trimAdjustedOwnCurrentTimeMs -
364
- this.targetElement.audioBufferTask.value.startOffsetMs) /
365
- 1000,
366
- );
367
-
368
- audioBufferSource.start(0, startTime, FRAME_SMEAR_S);
369
- await audioContext.startRendering();
370
-
371
- const frameData = new Uint8Array(analyser.frequencyBinCount);
372
- analyser.getByteFrequencyData(frameData);
373
-
374
- const smoothedData = frameData.slice(0, frameData.length / 2);
375
-
376
- if (this.color === "currentColor") {
377
- const computedStyle = getComputedStyle(this);
378
- const currentColor = computedStyle.color;
379
- ctx.strokeStyle = currentColor;
380
- ctx.fillStyle = currentColor;
381
- } else {
382
- }
386
+ if (this.color === "currentColor") {
387
+ const computedStyle = getComputedStyle(this);
388
+ const currentColor = computedStyle.color;
389
+ ctx.strokeStyle = currentColor;
390
+ ctx.fillStyle = currentColor;
391
+ }
383
392
 
384
- switch (this.mode) {
385
- case "bars":
386
- this.drawBars(ctx, smoothedData);
387
- break;
388
- case "bricks":
389
- this.drawBricks(ctx, smoothedData);
390
- break;
391
- case "curve":
392
- this.drawCurve(ctx, smoothedData);
393
- break;
394
- case "line":
395
- this.drawLine(ctx, smoothedData);
396
- break;
397
- case "pixel":
398
- this.drawPixel(ctx, smoothedData);
399
- break;
400
- case "wave":
401
- this.drawWave(ctx, smoothedData);
402
- break;
403
- case "roundBars":
404
- this.drawRoundBars(ctx, smoothedData);
405
- break;
406
- case "equalizer":
407
- this.drawEqualizer(ctx, smoothedData);
408
- break;
409
- }
393
+ switch (this.mode) {
394
+ case "bars":
395
+ this.drawBars(ctx, frequencyData);
396
+ break;
397
+ case "bricks":
398
+ this.drawBricks(ctx, frequencyData);
399
+ break;
400
+ case "line":
401
+ this.drawLine(ctx, frequencyData);
402
+ break;
403
+ case "pixel":
404
+ this.drawPixel(ctx, frequencyData);
405
+ break;
406
+ case "wave":
407
+ this.drawWave(ctx, frequencyData);
408
+ break;
409
+ case "roundBars":
410
+ this.drawRoundBars(ctx, frequencyData);
411
+ break;
410
412
  }
411
413
  },
412
414
  });
413
415
 
414
416
  get durationMs() {
417
+ if (!this.targetElement) return 0;
415
418
  return this.targetElement.durationMs;
416
419
  }
417
420
 
@@ -420,7 +423,7 @@ export class EFWaveform extends EFTemporal(TWMixin(LitElement)) {
420
423
  if (target instanceof EFAudio || target instanceof EFVideo) {
421
424
  return target;
422
425
  }
423
- throw new Error("Invalid target, must be an EFAudio or EFVideo element");
426
+ return null;
424
427
  }
425
428
 
426
429
  protected updated(changedProperties: PropertyValueMap<this>): void {
@@ -107,7 +107,7 @@ export class EFWorkbench extends ContextMixin(TWMixin(LitElement)) {
107
107
  focusOverlay.style.display = "block";
108
108
  const rect = this.focusedElement.getBoundingClientRect();
109
109
  Object.assign(focusOverlay.style, {
110
- position: "absolute",
110
+ position: "fixed",
111
111
  top: `${rect.top}px`,
112
112
  left: `${rect.left}px`,
113
113
  width: `${rect.width}px`,