@jackuait/blok 0.18.0 → 0.19.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,4 +1,5 @@
1
1
  import { WAVEFORM_BUCKETS } from './constants';
2
+ import { liveAmplitude, headColorBlend, entranceEase } from './liveliness';
2
3
 
3
4
  /**
4
5
  * Reduce raw mono samples to `buckets` peak values normalized to 0..1.
@@ -95,16 +96,51 @@ export function attachWaveform(opts: {
95
96
  canvas.className = 'blok-audio-waveform__canvas';
96
97
  mount.appendChild(canvas);
97
98
 
98
- const draw = (): void => {
99
+ const prefersReducedMotion = (): boolean =>
100
+ globalThis.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false;
101
+
102
+ // Animation state. `playing` is true between play and pause/ended and pins the
103
+ // boost energy at full. On pause we keep the loop alive briefly with `settling`
104
+ // so the bars glide back to their resting peaks (energy ramps 1→0) instead of
105
+ // snapping. `energy` reflects the current boost multiplier.
106
+ const SETTLE_MS = 420;
107
+ // The comet-head colour blooms in over ENTRANCE_MS each time playback starts.
108
+ const ENTRANCE_MS = 650;
109
+ const anim = { playing: false, settling: false, settleStart: 0, entranceStart: 0, rafId: 0 };
110
+
111
+ const energyAt = (now: number): number => {
112
+ if (anim.playing) return 1;
113
+ if (!anim.settling) return 0;
114
+ return Math.min(1, Math.max(0, 1 - (now - anim.settleStart) / SETTLE_MS));
115
+ };
116
+
117
+ const entranceAt = (now: number): number => entranceEase((now - anim.entranceStart) / ENTRANCE_MS);
118
+
119
+ const draw = (now: number): void => {
99
120
  const rect = canvas.getBoundingClientRect();
100
121
  if (rect.width <= 0 || rect.height <= 0) return;
101
122
  const dpr = globalThis.devicePixelRatio || 1;
102
- canvas.width = Math.round(rect.width * dpr);
103
- canvas.height = Math.round(rect.height * dpr);
123
+ const targetW = Math.round(rect.width * dpr);
124
+ const targetH = Math.round(rect.height * dpr);
125
+ // Resizing the backing store clears it; only pay that cost when the box
126
+ // actually changed (every frame of the loop would otherwise reallocate).
127
+ if (canvas.width !== targetW || canvas.height !== targetH) {
128
+ canvas.width = targetW;
129
+ canvas.height = targetH;
130
+ }
104
131
  const ctx = canvas.getContext('2d');
105
132
  if (!ctx) return;
106
- ctx.scale(dpr, dpr);
133
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
134
+ ctx.clearRect(0, 0, rect.width, rect.height);
135
+
107
136
  const played = media.duration ? media.currentTime / media.duration : 0;
137
+ const playheadIndex = played * peaks.length;
138
+ const reduced = prefersReducedMotion();
139
+ const timeSeconds = now / 1000;
140
+ const energy = reduced ? 0 : energyAt(now);
141
+ const live = energy > 0;
142
+ const entrance = live ? entranceAt(now) : 0;
143
+
108
144
  const slot = rect.width / peaks.length;
109
145
  const gap = Math.min(2, slot * 0.34);
110
146
  const barW = Math.max(1, slot - gap);
@@ -112,11 +148,9 @@ export function attachWaveform(opts: {
112
148
  const styles = getComputedStyle(canvas);
113
149
  const playedColor = styles.getPropertyValue('--blok-audio-bar-played').trim() || '#222';
114
150
  const baseColor = styles.getPropertyValue('--blok-audio-bar').trim() || '#ccc';
115
- peaks.forEach((peak, i) => {
116
- const h = Math.max(2, peak * rect.height * 0.92);
117
- const x = i * slot;
118
- const y = (rect.height - h) / 2;
119
- ctx.fillStyle = i / peaks.length < played ? playedColor : baseColor;
151
+ const headColor = styles.getPropertyValue('--blok-audio-bar-head').trim() || playedColor;
152
+
153
+ const paintBar = (x: number, y: number, h: number): void => {
120
154
  if (typeof ctx.roundRect === 'function') {
121
155
  ctx.beginPath();
122
156
  ctx.roundRect(x, y, barW, h, radius);
@@ -124,26 +158,91 @@ export function attachWaveform(opts: {
124
158
  } else {
125
159
  ctx.fillRect(x, y, barW, h);
126
160
  }
161
+ };
162
+
163
+ peaks.forEach((peak, i) => {
164
+ const amp = live
165
+ ? liveAmplitude({ basePeak: peak, index: i, playheadIndex, timeSeconds, reduced, energy })
166
+ : peak;
167
+ const h = Math.max(2, amp * rect.height * 0.92);
168
+ const x = i * slot;
169
+ const y = (rect.height - h) / 2;
170
+ ctx.fillStyle = i < playheadIndex ? playedColor : baseColor;
171
+ paintBar(x, y, h);
172
+
173
+ // Comet-head colour shift — overlay the head tint, its alpha ramping in
174
+ // with the entrance and fading out on the settle (energy).
175
+ if (live) {
176
+ const blend = headColorBlend({ distance: i - playheadIndex, energy, entrance });
177
+ if (blend > 0.001) {
178
+ ctx.save();
179
+ ctx.globalAlpha = Math.min(1, blend);
180
+ ctx.fillStyle = headColor;
181
+ paintBar(x, y, h);
182
+ ctx.restore();
183
+ }
184
+ }
127
185
  });
128
186
  };
129
187
 
130
- const rafState = { id: 0 };
188
+ const setSeekVar = (): void => {
189
+ mount.style.setProperty(
190
+ '--blok-audio-seek-pct',
191
+ String(media.duration ? (media.currentTime / media.duration) * 100 : 0),
192
+ );
193
+ };
194
+
195
+ const loop = (now: number): void => {
196
+ draw(now);
197
+ // Keep looping while playing, or while the post-pause settle still has energy.
198
+ const keepGoing = anim.playing || (anim.settling && energyAt(now) > 0);
199
+ if (keepGoing) {
200
+ anim.rafId = requestAnimationFrame(loop);
201
+ } else {
202
+ anim.settling = false;
203
+ anim.rafId = 0;
204
+ }
205
+ };
206
+
207
+ const onPlay = (): void => {
208
+ setSeekVar();
209
+ anim.playing = true;
210
+ anim.settling = false;
211
+ // Restart the head-colour entrance so it blooms in on every play.
212
+ anim.entranceStart = globalThis.performance?.now?.() ?? 0;
213
+ // No continuous loop under reduced motion — timeupdate alone advances the
214
+ // played boundary, with no dancing bars.
215
+ if (!anim.rafId && !prefersReducedMotion()) anim.rafId = requestAnimationFrame(loop);
216
+ };
217
+ const onStop = (): void => {
218
+ // Hand off to the settle ramp so the bars glide down rather than snap. Under
219
+ // reduced motion there's no loop/boost to settle, so just stop.
220
+ if (anim.playing && !prefersReducedMotion()) {
221
+ anim.playing = false;
222
+ anim.settling = true;
223
+ anim.settleStart = globalThis.performance?.now?.() ?? 0;
224
+ if (!anim.rafId) anim.rafId = requestAnimationFrame(loop);
225
+ } else {
226
+ anim.playing = false;
227
+ anim.settling = false;
228
+ if (anim.rafId) { cancelAnimationFrame(anim.rafId); anim.rafId = 0; }
229
+ draw(0);
230
+ }
231
+ };
232
+
233
+ // Keeps the played boundary + seek var current while paused-seeking or when
234
+ // the continuous loop is off (reduced motion). The loop owns drawing while it
235
+ // runs, so skip the redundant paint then.
131
236
  const onTime = (): void => {
132
- if (rafState.id) return;
133
- rafState.id = requestAnimationFrame(() => {
134
- rafState.id = 0;
135
- mount.style.setProperty(
136
- '--blok-audio-seek-pct',
137
- String(media.duration ? (media.currentTime / media.duration) * 100 : 0),
138
- );
139
- draw();
140
- });
237
+ setSeekVar();
238
+ if (!anim.rafId) draw(0);
141
239
  };
142
240
 
143
241
  const seek = (clientX: number): void => {
144
242
  if (!media.duration) return;
145
243
  media.currentTime = ratioFromPointer(clientX, canvas.getBoundingClientRect()) * media.duration;
146
- draw();
244
+ setSeekVar();
245
+ if (!anim.rafId) draw(0);
147
246
  };
148
247
  const drag = { active: false };
149
248
  const onDown = (e: PointerEvent): void => { drag.active = true; seek(e.clientX); };
@@ -153,18 +252,25 @@ export function attachWaveform(opts: {
153
252
  canvas.addEventListener('pointerdown', onDown);
154
253
  globalThis.addEventListener('pointermove', onMove);
155
254
  globalThis.addEventListener('pointerup', onUp);
255
+ media.addEventListener('play', onPlay);
256
+ media.addEventListener('pause', onStop);
257
+ media.addEventListener('ended', onStop);
156
258
  media.addEventListener('timeupdate', onTime);
157
- media.addEventListener('loadedmetadata', draw);
158
- draw();
259
+ media.addEventListener('loadedmetadata', onTime);
260
+ draw(0);
159
261
 
160
262
  return {
161
263
  destroy(): void {
162
- if (rafState.id) cancelAnimationFrame(rafState.id);
264
+ if (anim.rafId) cancelAnimationFrame(anim.rafId);
265
+ anim.playing = false;
163
266
  canvas.removeEventListener('pointerdown', onDown);
164
267
  globalThis.removeEventListener('pointermove', onMove);
165
268
  globalThis.removeEventListener('pointerup', onUp);
269
+ media.removeEventListener('play', onPlay);
270
+ media.removeEventListener('pause', onStop);
271
+ media.removeEventListener('ended', onStop);
166
272
  media.removeEventListener('timeupdate', onTime);
167
- media.removeEventListener('loadedmetadata', draw);
273
+ media.removeEventListener('loadedmetadata', onTime);
168
274
  canvas.remove();
169
275
  },
170
276
  };
@@ -18,3 +18,4 @@ export * from './ui';
18
18
  export * from './tools';
19
19
 
20
20
  export * from './theme';
21
+ export * from './width';
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Editor content width mode.
3
+ *
4
+ * - `'narrow'` — content is constrained to the default `--max-width-content` (the default).
5
+ * - `'full'` — content max-width constraint is removed so it fills its container.
6
+ */
7
+ export type EditorWidth = 'full' | 'narrow';
8
+
9
+ /**
10
+ * Describes Blok's width API for controlling the editor content width mode.
11
+ */
12
+ export interface Width {
13
+ /**
14
+ * Returns the current editor content width mode.
15
+ *
16
+ * @returns {EditorWidth} the active mode (defaults to `'narrow'`)
17
+ */
18
+ get(): EditorWidth;
19
+
20
+ /**
21
+ * Sets the editor content width mode.
22
+ *
23
+ * @param {EditorWidth} value - the mode to apply
24
+ */
25
+ set(value: EditorWidth): void;
26
+
27
+ /**
28
+ * Toggles the editor content width mode between `'narrow'` and `'full'`.
29
+ */
30
+ toggle(): void;
31
+ }
@@ -28,6 +28,7 @@ export const DATA_ATTR: {
28
28
 
29
29
  // Editor Modes
30
30
  readonly rtl: 'data-blok-rtl';
31
+ readonly width: 'data-blok-width';
31
32
 
32
33
  // Drag and Drop
33
34
  readonly dragging: 'data-blok-dragging';
package/types/index.d.ts CHANGED
@@ -33,6 +33,7 @@ import {
33
33
  Theme,
34
34
  ThemeMode,
35
35
  ResolvedTheme,
36
+ Width,
36
37
  } from './api';
37
38
 
38
39
  import { OutputData } from './data-formats';
@@ -129,6 +130,8 @@ export {
129
130
  Theme,
130
131
  ThemeMode,
131
132
  ResolvedTheme,
133
+ Width,
134
+ EditorWidth,
132
135
  } from './api';
133
136
  export {
134
137
  BlockMutationType,
@@ -204,14 +207,41 @@ export function wrapLegacyInlineTool(
204
207
  }
205
208
  ): InlineToolConstructable;
206
209
 
210
+ /**
211
+ * The surface of a Blok instance that is guaranteed to exist synchronously,
212
+ * immediately after `new Blok()` and before `isReady` has resolved.
213
+ *
214
+ * Blok constructs its module APIs (`blocks`, `caret`, `history`, `readOnly`, …)
215
+ * asynchronously: they only become available once `isReady` resolves. Accessing
216
+ * them earlier returns `undefined` at runtime. Type a reference you hold during
217
+ * that window as `PendingBlok` so the not-yet-available members are unreachable,
218
+ * then await `isReady` to obtain the fully-initialized {@link Blok}:
219
+ *
220
+ * @example
221
+ * const pending: PendingBlok = new Blok(config);
222
+ * const editor = await pending.isReady; // editor is a ready Blok
223
+ * editor.blocks.render(data);
224
+ */
225
+ export interface PendingBlok {
226
+ /** Resolves with the fully-initialized Blok instance once core modules are ready. */
227
+ isReady: Promise<Blok>;
228
+ /** Destroy the instance. Safe to call before `isReady` resolves. */
229
+ destroy(): void;
230
+ /** Theme API, exposed immediately after construction. */
231
+ theme: Theme;
232
+ /** Width API, exposed immediately after construction. */
233
+ width: Width;
234
+ }
235
+
207
236
  /**
208
237
  * Main Blok class
209
238
  */
210
239
  export class Blok {
211
- public isReady: Promise<void>;
240
+ public isReady: Promise<Blok>;
212
241
 
213
242
  public blocks: Blocks;
214
243
  public caret: Caret;
244
+ public history: History;
215
245
  public sanitizer: Sanitizer;
216
246
  public saver: Saver;
217
247
  public selection: Selection;
@@ -221,6 +251,7 @@ export class Blok {
221
251
  public tooltip: Tooltip;
222
252
  public readOnly: ReadOnly;
223
253
  public theme: Theme;
254
+ public width: Width;
224
255
  constructor(configuration?: BlokConfig|string);
225
256
 
226
257
  /**