@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.
package/dist/full.cjs CHANGED
@@ -1 +1 @@
1
- Object.defineProperty(exports,Symbol.toStringTag,{value:`Module`});const e=require(`./chunks/blok-B7IRrvgX.cjs`),t=require(`./chunks/constants-BWoyU4yv.cjs`),n=require(`./chunks/objectSpread2-iNkE6aJh.cjs`),r=require(`./chunks/tools-DXagFf6I.cjs`);var i={paragraph:{class:r.k,inlineToolbar:!0,config:{preserveBlank:!0}},header:{class:r.O,inlineToolbar:!0},list:{class:r.D,inlineToolbar:!0}},a=n.t(n.t({},i),{},{bold:{class:r.l},italic:{class:r.c},marker:{class:r.o},underline:{class:r.a},strikethrough:{class:r.i},link:{class:r.s}});exports.Blok=e.t,exports.Bold=r.l,exports.DATA_ATTR=t.Pi,exports.Header=r.O,exports.Italic=r.c,exports.Link=r.s,exports.List=r.D,exports.Marker=r.o,exports.Paragraph=r.k,exports.Strikethrough=r.i,exports.Toggle=r.T,exports.Underline=r.a,exports.allTools=a,exports.defaultBlockTools=r.t,exports.defaultInlineTools=r.n,exports.defaultTools=i,exports.version=e.n;
1
+ Object.defineProperty(exports,Symbol.toStringTag,{value:`Module`});const e=require(`./chunks/blok-gFn3FI95.cjs`),t=require(`./chunks/constants-DmKwEMyC.cjs`),n=require(`./chunks/objectSpread2-iNkE6aJh.cjs`),r=require(`./chunks/tools-BcF1rHBg.cjs`);var i={paragraph:{class:r.k,inlineToolbar:!0,config:{preserveBlank:!0}},header:{class:r.O,inlineToolbar:!0},list:{class:r.D,inlineToolbar:!0}},a=n.t(n.t({},i),{},{bold:{class:r.l},italic:{class:r.c},marker:{class:r.o},underline:{class:r.a},strikethrough:{class:r.i},link:{class:r.s}});exports.Blok=e.t,exports.Bold=r.l,exports.DATA_ATTR=t.Pi,exports.Header=r.O,exports.Italic=r.c,exports.Link=r.s,exports.List=r.D,exports.Marker=r.o,exports.Paragraph=r.k,exports.Strikethrough=r.i,exports.Toggle=r.T,exports.Underline=r.a,exports.allTools=a,exports.defaultBlockTools=r.t,exports.defaultInlineTools=r.n,exports.defaultTools=i,exports.version=e.n;
package/dist/full.mjs CHANGED
@@ -1,7 +1,7 @@
1
- import { n as e, t } from "./chunks/blok-BoAuTqli.mjs";
2
- import { Pi as n } from "./chunks/constants-BO6zLfL2.mjs";
1
+ import { n as e, t } from "./chunks/blok-C52qeiPg.mjs";
2
+ import { Pi as n } from "./chunks/constants-DzQOOvgv.mjs";
3
3
  import { t as r } from "./chunks/objectSpread2-jlBcdYeb.mjs";
4
- import { D as i, O as a, T as o, a as s, c, i as l, k as u, l as d, n as f, o as p, s as m, t as h } from "./chunks/tools-DRwLS-6t.mjs";
4
+ import { D as i, O as a, T as o, a as s, c, i as l, k as u, l as d, n as f, o as p, s as m, t as h } from "./chunks/tools-CelSzJoA.mjs";
5
5
  //#region src/full.ts
6
6
  var g = {
7
7
  paragraph: {
package/dist/react.cjs CHANGED
@@ -1 +1 @@
1
- Object.defineProperty(exports,Symbol.toStringTag,{value:`Module`}),require(`./chunks/chunk-BL1yB9zm.cjs`);const e=require(`./chunks/blok-B7IRrvgX.cjs`),t=require(`./chunks/objectSpread2-iNkE6aJh.cjs`);let n=require(`react`),r=require(`react/jsx-runtime`);var i=new WeakMap;function a(e,t){i.set(e,t)}function o(e){return i.get(e)}function s(e){i.delete(e)}function c(r,i){let[o,c]=(0,n.useState)(null),u=(0,n.useRef)(r);u.current=r;let d=(0,n.useRef)({editor:null,holder:null,destroyTimeout:null,isDestroyed:!1,depsToken:null}),f=(0,n.useMemo)(()=>({}),i==null?[]:i);(0,n.useEffect)(()=>{if(typeof window>`u`)return;let n=d.current;if(n.destroyTimeout!==null&&(clearTimeout(n.destroyTimeout),n.destroyTimeout=null),n.editor!==null&&!n.isDestroyed&&n.depsToken===f)return c(n.editor),()=>{l(n,c)};if(n.editor!==null&&!n.isDestroyed){s(n.editor);try{n.editor.destroy()}catch(e){}n.editor=null,n.holder=null,n.isDestroyed=!0,c(null)}let r=document.createElement(`div`);n.holder=r,n.isDestroyed=!1,n.depsToken=f;let i=u.current,o=new e.t(t.t(t.t({},i),{},{holder:r,onReady:()=>{var e,t;(e=(t=u.current).onReady)==null||e.call(t)},onChange:(...e)=>{var t,n;(t=(n=u.current).onChange)==null||t.call(n,...e)}}));return n.editor=o,a(o,r),o.isReady.then(()=>{n.editor===o&&!n.isDestroyed&&c(o)}).catch(()=>{if(n.editor===o&&!n.isDestroyed){s(o);try{o.destroy()}catch(e){}n.editor=null,n.holder=null,n.isDestroyed=!0,c(null)}}),()=>{l(n,c)}},[f]);let{readOnly:p}=r;(0,n.useEffect)(()=>{o!==null&&o.readOnly.set(p==null?!1:p)},[o,p]);let{autofocus:m}=r;return(0,n.useEffect)(()=>{o===null||!m||o.focus()},[o,m]),o}function l(e,t){e.destroyTimeout=setTimeout(()=>{if(e.editor!==null){s(e.editor);try{e.editor.destroy()}catch(e){}e.editor=null,e.holder=null,e.isDestroyed=!0,e.destroyTimeout=null,t(null)}},0)}var u=[`editor`],d=(0,n.forwardRef)(function(i,a){let{editor:s}=i,c=e.i(i,u),l=(0,n.useRef)(null);return(0,n.useEffect)(()=>{if(s===null||l.current===null)return;let e=o(s);if(e!==void 0)return l.current.appendChild(e),()=>{e.remove()}},[s]),(0,r.jsx)(`div`,t.t({ref:e=>{if(l.current=e,typeof a==`function`)a(e);else if(a!=null){let t=a;t.current=e}}},c))});exports.BlokContent=d,exports.useBlok=c;
1
+ Object.defineProperty(exports,Symbol.toStringTag,{value:`Module`}),require(`./chunks/chunk-BL1yB9zm.cjs`);const e=require(`./chunks/blok-gFn3FI95.cjs`),t=require(`./chunks/objectSpread2-iNkE6aJh.cjs`);let n=require(`react`),r=require(`react/jsx-runtime`);var i=new WeakMap;function a(e,t){i.set(e,t)}function o(e){return i.get(e)}function s(e){i.delete(e)}function c(r,i){let[o,c]=(0,n.useState)(null),u=(0,n.useRef)(r);u.current=r;let d=(0,n.useRef)({editor:null,holder:null,destroyTimeout:null,isDestroyed:!1,depsToken:null}),f=(0,n.useMemo)(()=>({}),i==null?[]:i);(0,n.useEffect)(()=>{if(typeof window>`u`)return;let n=d.current;if(n.destroyTimeout!==null&&(clearTimeout(n.destroyTimeout),n.destroyTimeout=null),n.editor!==null&&!n.isDestroyed&&n.depsToken===f)return c(n.editor),()=>{l(n,c)};if(n.editor!==null&&!n.isDestroyed){s(n.editor);try{n.editor.destroy()}catch(e){}n.editor=null,n.holder=null,n.isDestroyed=!0,c(null)}let r=document.createElement(`div`);n.holder=r,n.isDestroyed=!1,n.depsToken=f;let i=u.current,o=new e.t(t.t(t.t({},i),{},{holder:r,onReady:()=>{var e,t;(e=(t=u.current).onReady)==null||e.call(t)},onChange:(...e)=>{var t,n;(t=(n=u.current).onChange)==null||t.call(n,...e)}}));return n.editor=o,a(o,r),o.isReady.then(()=>{n.editor===o&&!n.isDestroyed&&c(o)}).catch(()=>{if(n.editor===o&&!n.isDestroyed){s(o);try{o.destroy()}catch(e){}n.editor=null,n.holder=null,n.isDestroyed=!0,c(null)}}),()=>{l(n,c)}},[f]);let{readOnly:p}=r;(0,n.useEffect)(()=>{o!==null&&o.readOnly.set(p==null?!1:p)},[o,p]);let{autofocus:m}=r;return(0,n.useEffect)(()=>{o===null||!m||o.focus()},[o,m]),o}function l(e,t){e.destroyTimeout=setTimeout(()=>{if(e.editor!==null){s(e.editor);try{e.editor.destroy()}catch(e){}e.editor=null,e.holder=null,e.isDestroyed=!0,e.destroyTimeout=null,t(null)}},0)}var u=[`editor`],d=(0,n.forwardRef)(function(i,a){let{editor:s}=i,c=e.i(i,u),l=(0,n.useRef)(null);return(0,n.useEffect)(()=>{if(s===null||l.current===null)return;let e=o(s);if(e!==void 0)return l.current.appendChild(e),()=>{e.remove()}},[s]),(0,r.jsx)(`div`,t.t({ref:e=>{if(l.current=e,typeof a==`function`)a(e);else if(a!=null){let t=a;t.current=e}}},c))});exports.BlokContent=d,exports.useBlok=c;
package/dist/react.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { i as e, t } from "./chunks/blok-BoAuTqli.mjs";
1
+ import { i as e, t } from "./chunks/blok-C52qeiPg.mjs";
2
2
  import { t as n } from "./chunks/objectSpread2-jlBcdYeb.mjs";
3
3
  import { forwardRef as r, useEffect as i, useMemo as a, useRef as o, useState as s } from "react";
4
4
  import { jsx as c } from "react/jsx-runtime";
package/dist/tools.cjs CHANGED
@@ -1 +1 @@
1
- Object.defineProperty(exports,Symbol.toStringTag,{value:`Module`});const e=require(`./chunks/constants-BWoyU4yv.cjs`),t=require(`./chunks/tools-DXagFf6I.cjs`);exports.Audio=t.g,exports.Bold=t.l,exports.Bookmark=t.u,exports.Callout=t.w,exports.Code=t.y,exports.Column=t.p,exports.ColumnList=t.m,exports.Columns=t.f,exports.Convert=e.m,exports.Database=t.C,exports.DatabaseRow=t.S,exports.Divider=t.x,exports.Embed=t.d,exports.File=t._,exports.Header=t.O,exports.Image=t.v,exports.InlineCode=t.r,exports.Italic=t.c,exports.Link=t.s,exports.List=t.D,exports.Marker=t.o,exports.Paragraph=t.k,exports.Quote=t.b,exports.Strikethrough=t.i,exports.Table=t.E,exports.Toggle=t.T,exports.Underline=t.a,exports.Video=t.h,exports.defaultBlockTools=t.t,exports.defaultInlineTools=t.n;
1
+ Object.defineProperty(exports,Symbol.toStringTag,{value:`Module`});const e=require(`./chunks/constants-DmKwEMyC.cjs`),t=require(`./chunks/tools-BcF1rHBg.cjs`);exports.Audio=t.g,exports.Bold=t.l,exports.Bookmark=t.u,exports.Callout=t.w,exports.Code=t.y,exports.Column=t.p,exports.ColumnList=t.m,exports.Columns=t.f,exports.Convert=e.m,exports.Database=t.C,exports.DatabaseRow=t.S,exports.Divider=t.x,exports.Embed=t.d,exports.File=t._,exports.Header=t.O,exports.Image=t.v,exports.InlineCode=t.r,exports.Italic=t.c,exports.Link=t.s,exports.List=t.D,exports.Marker=t.o,exports.Paragraph=t.k,exports.Quote=t.b,exports.Strikethrough=t.i,exports.Table=t.E,exports.Toggle=t.T,exports.Underline=t.a,exports.Video=t.h,exports.defaultBlockTools=t.t,exports.defaultInlineTools=t.n;
package/dist/tools.mjs CHANGED
@@ -1,3 +1,3 @@
1
- import { m as e } from "./chunks/constants-BO6zLfL2.mjs";
2
- import { C as t, D as n, E as r, O as i, S as a, T as o, _ as s, a as c, b as l, c as u, d, f, g as p, h as m, i as h, k as g, l as _, m as v, n as y, o as b, p as x, r as S, s as C, t as w, u as T, v as E, w as D, x as O, y as k } from "./chunks/tools-DRwLS-6t.mjs";
1
+ import { m as e } from "./chunks/constants-DzQOOvgv.mjs";
2
+ import { C as t, D as n, E as r, O as i, S as a, T as o, _ as s, a as c, b as l, c as u, d, f, g as p, h as m, i as h, k as g, l as _, m as v, n as y, o as b, p as x, r as S, s as C, t as w, u as T, v as E, w as D, x as O, y as k } from "./chunks/tools-CelSzJoA.mjs";
3
3
  export { p as Audio, _ as Bold, T as Bookmark, D as Callout, k as Code, x as Column, v as ColumnList, f as Columns, e as Convert, t as Database, a as DatabaseRow, O as Divider, d as Embed, s as File, i as Header, E as Image, S as InlineCode, u as Italic, C as Link, n as List, b as Marker, g as Paragraph, l as Quote, h as Strikethrough, r as Table, o as Toggle, c as Underline, m as Video, w as defaultBlockTools, y as defaultInlineTools };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jackuait/blok",
3
- "version": "0.18.0",
3
+ "version": "0.19.1",
4
4
  "description": "Blok — headless, highly extensible rich text editor built for developers who need to implement a block-based editing experience (similar to Notion) without building it from scratch",
5
5
  "module": "dist/blok.mjs",
6
6
  "main": "./dist/blok.cjs",
package/src/blok.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  */
6
6
  import '@babel/register';
7
7
 
8
- import type { BlokConfig, API } from '../types';
8
+ import type { BlokConfig, API, EditorWidth } from '../types';
9
9
 
10
10
  import { DATA_ATTR } from './components/constants/data-attributes';
11
11
  import { Core } from './components/core';
@@ -36,9 +36,11 @@ class Blok {
36
36
  private readonly initialConfiguration: BlokConfig|string|undefined;
37
37
 
38
38
  /**
39
- * Promise that resolves when core modules are ready and UI is rendered on the page
39
+ * Promise that resolves when core modules are ready and UI is rendered on the page.
40
+ * Resolves with the fully-initialized Blok instance, so awaiting it narrows a
41
+ * pre-ready reference (see {@link PendingBlok}) to the complete API surface.
40
42
  */
41
- public isReady: Promise<void>;
43
+ public isReady: Promise<Blok>;
42
44
 
43
45
  /**
44
46
  * Stores destroy method implementation.
@@ -141,6 +143,38 @@ class Blok {
141
143
  },
142
144
  };
143
145
 
146
+ /**
147
+ * Width API — expose it on the instance immediately (mirrors the theme API),
148
+ * so host apps can call instance.width.set('full') before isReady without a
149
+ * silent no-op. The chosen mode is buffered and replayed once UI is ready.
150
+ */
151
+ const widthBuffer = { pendingMode: null as EditorWidth | null };
152
+
153
+ const getUIModule = (): BlokModules['UI'] | undefined =>
154
+ (blok.moduleInstances as Partial<BlokModules>).UI;
155
+
156
+ const readWidthMode = (): EditorWidth => {
157
+ const ui = getUIModule();
158
+
159
+ return ui !== undefined ? ui.getWidthMode() : (widthBuffer.pendingMode ?? 'narrow');
160
+ };
161
+
162
+ const applyWidthMode = (mode: EditorWidth): void => {
163
+ widthBuffer.pendingMode = mode;
164
+
165
+ const ui = getUIModule();
166
+
167
+ if (ui !== undefined) {
168
+ ui.setWidthMode(mode);
169
+ }
170
+ };
171
+
172
+ (this as Record<string, unknown>).width = {
173
+ get: (): EditorWidth => readWidthMode(),
174
+ set: (mode: EditorWidth): void => applyWidthMode(mode),
175
+ toggle: (): void => applyWidthMode(readWidthMode() === 'full' ? 'narrow' : 'full'),
176
+ };
177
+
144
178
  /**
145
179
  * We need to export isReady promise in the constructor
146
180
  * as it can be used before other API methods are exported
@@ -186,7 +220,7 @@ class Blok {
186
220
 
187
221
  Object.setPrototypeOf(this, null);
188
222
 
189
- return;
223
+ return this;
190
224
  }
191
225
 
192
226
  this.exportAPI(blok);
@@ -203,6 +237,16 @@ class Blok {
203
237
  themeBuffer.pendingMode = null;
204
238
  }
205
239
 
240
+ // Apply any width mode buffered before isReady resolved.
241
+ if (widthBuffer.pendingMode !== null) {
242
+ const ui = (blok.moduleInstances as Partial<BlokModules>).UI;
243
+
244
+ if (ui !== undefined) {
245
+ ui.setWidthMode(widthBuffer.pendingMode);
246
+ }
247
+ widthBuffer.pendingMode = null;
248
+ }
249
+
206
250
  // Scroll to the block referenced by the URL hash, if present.
207
251
  // isReady resolves only after all blocks are in the DOM (requestIdleCallback fence in Renderer),
208
252
  // so no extra polling is needed even on slow connections.
@@ -234,6 +278,8 @@ class Blok {
234
278
  * @todo pass API as an argument. It will allow to use Blok's API when blok is ready
235
279
  */
236
280
  onReady();
281
+
282
+ return this;
237
283
  });
238
284
  }
239
285
 
@@ -63,6 +63,8 @@ export const DATA_ATTR = {
63
63
  contentAlign: 'data-blok-content-align',
64
64
  /** Right-to-left mode */
65
65
  rtl: 'data-blok-rtl',
66
+ /** Editor content width mode (present with value "full" for wide mode; absent = narrow) */
67
+ width: 'data-blok-width',
66
68
 
67
69
  // ============================================
68
70
  // Drag and Drop
@@ -3,6 +3,7 @@
3
3
  * Module UI
4
4
  * @type {UI}
5
5
  */
6
+ import type { EditorWidth } from '../../../types/api/width';
6
7
  import styles from '../../styles/main.css?inline';
7
8
  import { Module } from '../__module';
8
9
  import {
@@ -347,6 +348,35 @@ export class UI extends Module<UINodes> {
347
348
  this.nodes.wrapper.setAttribute(DATA_ATTR.empty, BlockManager.isBlokEmpty ? 'true' : 'false');
348
349
  }
349
350
 
351
+ /**
352
+ * Current editor content width mode. Defaults to 'narrow'.
353
+ */
354
+ private widthMode: EditorWidth = 'narrow';
355
+
356
+ /**
357
+ * Returns the current editor content width mode.
358
+ */
359
+ public getWidthMode(): EditorWidth {
360
+ return this.widthMode;
361
+ }
362
+
363
+ /**
364
+ * Sets the editor content width mode by writing the width data attribute on
365
+ * the editor wrapper. 'narrow' (the default) leaves the attribute absent so
366
+ * the content keeps its `--max-width-content` constraint; 'full' adds it so
367
+ * the dedicated CSS rule removes the constraint.
368
+ * @param mode - the width mode to apply
369
+ */
370
+ public setWidthMode(mode: EditorWidth): void {
371
+ this.widthMode = mode;
372
+
373
+ if (mode === 'full') {
374
+ this.nodes.wrapper.setAttribute(DATA_ATTR.width, 'full');
375
+ } else {
376
+ this.nodes.wrapper.removeAttribute(DATA_ATTR.width);
377
+ }
378
+ }
379
+
350
380
  /**
351
381
  * Check if one of Toolbar is opened
352
382
  * Used to prevent global keydowns (for example, Enter) conflicts with Enter-on-toolbar
@@ -25,6 +25,9 @@
25
25
  --blok-audio-play-size: 36px;
26
26
  --blok-audio-bar: var(--blok-border-subtle);
27
27
  --blok-audio-bar-played: var(--blok-text-primary);
28
+ /* Comet-head tint — played colour nudged toward the editor accent. Subtle by
29
+ design; the waveform canvas blends bars toward it only at the playhead. */
30
+ --blok-audio-bar-head: color-mix(in srgb, var(--blok-audio-bar-played) 68%, var(--blok-color-accent) 32%);
28
31
  --blok-audio-seek-pct: 0%;
29
32
  --blok-audio-vol-pct: 100%;
30
33
  --blok-audio-speed-pct: 50%;
@@ -382,20 +385,38 @@
382
385
  color: var(--blok-text-primary);
383
386
  }
384
387
 
385
- /* ----- caption row (full-width footer) -------------------- */
388
+ /* ----- caption row (full-width footer) --------------------
389
+ The row is a single-track grid wrapper so it can collapse to zero height
390
+ (grid-template-rows 1fr → 0fr) when the caption is toggled, while the inner
391
+ element holds the actual padding/border that fade out with it. */
386
392
  [data-blok-tool="audio"] .blok-audio-caption-row {
387
393
  grid-column: 1 / -1;
388
394
  grid-row: 2;
395
+ display: grid;
396
+ grid-template-rows: 1fr;
397
+ transition:
398
+ grid-template-rows var(--blok-duration-slow) var(--blok-ease-popover),
399
+ opacity var(--blok-duration-slow) var(--blok-ease-popover);
400
+ }
401
+ [data-blok-tool="audio"] .blok-audio-caption-row.is-collapsed {
402
+ grid-template-rows: 0fr;
403
+ opacity: 0;
404
+ }
405
+ /* overflow:hidden + min-height:0 lets the grid track collapse fully to 0; the
406
+ padding/border live on the caption child below so they get clipped too. */
407
+ [data-blok-tool="audio"] .blok-audio-caption-row__inner {
408
+ min-height: 0;
409
+ overflow: hidden;
389
410
  display: flex;
390
411
  align-items: flex-start;
391
412
  gap: var(--blok-space-2);
392
- padding-top: var(--blok-space-2-5);
393
- border-top: 1px solid var(--blok-border-subtle);
394
413
  }
395
414
  [data-blok-tool="audio"] .blok-audio-caption {
396
415
  flex: 1 1 auto;
397
416
  min-width: 0;
398
417
  display: block;
418
+ padding-top: var(--blok-space-2-5);
419
+ border-top: 1px solid var(--blok-border-subtle);
399
420
  font-size: var(--blok-font-size-ui-label);
400
421
  color: var(--blok-text-secondary);
401
422
  line-height: var(--blok-line-height-body);
@@ -525,4 +546,6 @@
525
546
  }
526
547
  /* The badge still appears while playing — it just holds steady. */
527
548
  [data-blok-tool="audio"] .blok-audio-eq { transition: none; }
549
+ /* Caption appears/disappears instantly (JS also takes the instant path). */
550
+ [data-blok-tool="audio"] .blok-audio-caption-row { transition: none; }
528
551
  }
@@ -273,6 +273,17 @@
273
273
  margin-left: auto;
274
274
  }
275
275
 
276
+ /**
277
+ * Content width mode
278
+ * 'full' (data-blok-width="full" on the editor wrapper) removes the
279
+ * --max-width-content constraint so content fills its container. The default
280
+ * 'narrow' mode leaves the attribute absent and keeps the max-w-blok-content
281
+ * Tailwind utility intact. Unlayered selector overrides the @layer utilities rule.
282
+ */
283
+ [data-blok-width="full"] [data-blok-element-content] {
284
+ max-width: none;
285
+ }
286
+
276
287
  /**
277
288
  * Multi-block drag visual feedback
278
289
  * Makes selected blocks semi-transparent during multi-block drag operation
@@ -52,6 +52,8 @@ export class AudioTool implements BlockTool {
52
52
  private controlsHandle: ControlsHandle | null = null;
53
53
  private waveformHandle: WaveformHandle | null = null;
54
54
  private destroyed = false;
55
+ /** Pending fallback timer for the caption collapse-out animation. */
56
+ private captionExitTimer: ReturnType<typeof setTimeout> | null = null;
55
57
 
56
58
  constructor(options: BlockToolConstructorOptions<AudioData, AudioConfig>) {
57
59
  this.api = options.api;
@@ -199,6 +201,7 @@ export class AudioTool implements BlockTool {
199
201
 
200
202
  public removed(): void {
201
203
  this.destroyed = true;
204
+ if (this.captionExitTimer) { clearTimeout(this.captionExitTimer); this.captionExitTimer = null; }
202
205
  this.controlsHandle?.destroy();
203
206
  this.controlsHandle = null;
204
207
  this.waveformHandle?.destroy();
@@ -299,6 +302,7 @@ export class AudioTool implements BlockTool {
299
302
 
300
303
  private renderState(): void {
301
304
  if (!this.root) return;
305
+ if (this.captionExitTimer) { clearTimeout(this.captionExitTimer); this.captionExitTimer = null; }
302
306
  this.controlsHandle?.destroy();
303
307
  this.controlsHandle = null;
304
308
  this.waveformHandle?.destroy();
@@ -393,14 +397,9 @@ export class AudioTool implements BlockTool {
393
397
  body.appendChild(this.controlsHandle.element);
394
398
 
395
399
  // Caption row — outside body, below the card
396
- const placeholder = this.config.captionPlaceholder ?? DEFAULT_CAPTION_PLACEHOLDER;
397
400
  const captionVisible = this.data.captionVisible !== false;
398
- if (captionVisible || !this.readOnly) {
399
- figure.appendChild(renderCaptionRow({
400
- value: this.data.caption ?? '',
401
- placeholder,
402
- editable: !this.readOnly,
403
- }));
401
+ if (captionVisible) {
402
+ figure.appendChild(this.createCaptionRow());
404
403
  }
405
404
 
406
405
  // Wire title/artist blur handlers
@@ -419,21 +418,32 @@ export class AudioTool implements BlockTool {
419
418
  }
420
419
  });
421
420
 
422
- // Wire caption blur handler
423
- const captionEl = figure.querySelector<HTMLElement>('[data-role="audio-caption"]');
424
- if (captionEl) {
425
- captionEl.addEventListener('blur', () => {
426
- const next = captionEl.textContent ?? '';
427
- if (next !== this.data.caption) {
428
- this.data.caption = next || undefined;
429
- this.block.dispatchChange();
430
- }
431
- });
432
- }
433
-
434
421
  this.root.appendChild(figure);
435
422
  }
436
423
 
424
+ /** Build a caption row (with its blur handler wired) ready to mount. */
425
+ private createCaptionRow(): HTMLElement {
426
+ const placeholder = this.config.captionPlaceholder ?? DEFAULT_CAPTION_PLACEHOLDER;
427
+ const row = renderCaptionRow({
428
+ value: this.data.caption ?? '',
429
+ placeholder,
430
+ editable: !this.readOnly,
431
+ });
432
+ const captionEl = row.querySelector<HTMLElement>('[data-role="audio-caption"]');
433
+ captionEl?.addEventListener('blur', () => {
434
+ const next = captionEl.textContent ?? '';
435
+ if (next !== this.data.caption) {
436
+ this.data.caption = next || undefined;
437
+ this.block.dispatchChange();
438
+ }
439
+ });
440
+ return row;
441
+ }
442
+
443
+ private prefersReducedMotion(): boolean {
444
+ return globalThis.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false;
445
+ }
446
+
437
447
  private setAlignment(next: AudioAlignment): void {
438
448
  if (this.data.alignment === next) return;
439
449
  this.data.alignment = next;
@@ -442,9 +452,54 @@ export class AudioTool implements BlockTool {
442
452
  }
443
453
 
444
454
  private toggleCaption(): void {
445
- this.data.captionVisible = this.data.captionVisible === false;
455
+ const enabling = this.data.captionVisible === false;
456
+ this.data.captionVisible = enabling;
457
+ // Removing the caption clears its text too, so re-enabling starts fresh
458
+ // rather than resurrecting the old text.
459
+ if (!enabling) this.data.caption = undefined;
446
460
  this.block.dispatchChange();
447
- this.renderState();
461
+
462
+ const figure = this.root?.querySelector<HTMLElement>('[data-role="audio-figure"]');
463
+ // No live card to animate (or motion is off) — fall back to an instant
464
+ // re-render so the caption snaps in/out.
465
+ if (!figure || this.prefersReducedMotion()) {
466
+ this.renderState();
467
+ return;
468
+ }
469
+ this.syncRootAttributes();
470
+ if (enabling) this.animateCaptionIn(figure);
471
+ else this.animateCaptionOut(figure);
472
+ }
473
+
474
+ private animateCaptionIn(figure: HTMLElement): void {
475
+ const existing = figure.querySelector<HTMLElement>('[data-role="audio-caption-row"]');
476
+ if (existing) {
477
+ // A collapse-out was mid-flight — cancel it and re-open in place.
478
+ if (this.captionExitTimer) { clearTimeout(this.captionExitTimer); this.captionExitTimer = null; }
479
+ existing.classList.remove('is-collapsed');
480
+ return;
481
+ }
482
+ const row = this.createCaptionRow();
483
+ row.classList.add('is-collapsed');
484
+ figure.appendChild(row);
485
+ // Force a reflow so the collapsed state is committed, then expand into view.
486
+ void row.offsetHeight;
487
+ row.classList.remove('is-collapsed');
488
+ }
489
+
490
+ private animateCaptionOut(figure: HTMLElement): void {
491
+ const row = figure.querySelector<HTMLElement>('[data-role="audio-caption-row"]');
492
+ if (!row) return;
493
+ row.classList.add('is-collapsed');
494
+ const finish = (): void => {
495
+ if (this.captionExitTimer) { clearTimeout(this.captionExitTimer); this.captionExitTimer = null; }
496
+ row.remove();
497
+ };
498
+ row.addEventListener('transitionend', (e) => {
499
+ if (e.propertyName === 'grid-template-rows') finish();
500
+ }, { once: true });
501
+ // Fallback in case transitionend never fires (display:none, interrupted, etc.).
502
+ this.captionExitTimer = setTimeout(finish, 360);
448
503
  }
449
504
 
450
505
  private toggleLoop(): void {
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Waveform "now playing" liveliness math.
3
+ *
4
+ * The cached peaks are static — there's no real-time FFT — so the sense of a
5
+ * live visualizer is composed from two layers:
6
+ *
7
+ * 1. A focal **comet** at the playhead: a bright head spike, a tight leading
8
+ * edge, and a longer decaying wake trailing into the already-played bars.
9
+ * It travels left→right as playback advances.
10
+ * 2. A global **ambient** ripple that flows across the entire field so no bar
11
+ * sits frozen. It's biased stronger on the played side (a shimmering wake
12
+ * of music) and faint on the not-yet-played preview ahead.
13
+ *
14
+ * All functions are pure so the behaviour is unit-testable; the canvas in
15
+ * waveform.ts just samples them per bar, per frame.
16
+ */
17
+
18
+ /** Bars ahead of the playhead (not yet played) that still react. */
19
+ export const REACH_AHEAD = 4;
20
+ /** Bars behind the playhead (already played) that keep a decaying wake. */
21
+ export const REACH_BEHIND = 9;
22
+
23
+ /**
24
+ * Asymmetric "comet" falloff around the playhead. `distance` is the bar index
25
+ * minus the fractional playhead index: negative = behind (already-played wake),
26
+ * positive = ahead (not yet played). The wake reaches further than the leading
27
+ * edge so the energy reads as travelling with momentum. 1 at the playhead,
28
+ * easing to 0 at the respective reach.
29
+ */
30
+ export function cometEnvelope(distance: number, reachAhead = REACH_AHEAD, reachBehind = REACH_BEHIND): number {
31
+ const reach = distance < 0 ? reachBehind : reachAhead;
32
+ const d = Math.abs(distance);
33
+ if (d >= reach) return 0;
34
+ const t = 1 - d / reach;
35
+ return t * t;
36
+ }
37
+
38
+ /**
39
+ * Tight symmetric spike centred on the playhead so the bar under it stands
40
+ * clearly proud of its neighbours — the bright head of the comet. Falls off
41
+ * faster (cubed) than the comet body.
42
+ */
43
+ export function headFocus(distance: number, reach = 3): number {
44
+ const d = Math.abs(distance);
45
+ if (d >= reach) return 0;
46
+ const t = 1 - d / reach;
47
+ return t * t * t;
48
+ }
49
+
50
+ /**
51
+ * Deterministic per-bar wobble in [-1, 1]. Two out-of-phase sines keyed to the
52
+ * bar index so neighbouring bars rise and fall independently — the cluster
53
+ * reads as a dancing equalizer rather than one uniform swell.
54
+ */
55
+ export function barWobble(index: number, timeSeconds: number): number {
56
+ const a = Math.sin(timeSeconds * 8.5 + index * 0.7);
57
+ const b = Math.sin(timeSeconds * 5.3 + index * 1.9);
58
+ return a * 0.6 + b * 0.4;
59
+ }
60
+
61
+ /**
62
+ * Slow travelling undulation across the whole bar field, in [-1, 1]. Two waves
63
+ * propagate in opposite directions (the `-index`/`+index` phase terms) so the
64
+ * field gently churns rather than sliding uniformly. Kept low-frequency so the
65
+ * motion is graceful, not busy.
66
+ */
67
+ export function ambientWave(index: number, timeSeconds: number): number {
68
+ const a = Math.sin(timeSeconds * 2.1 - index * 0.35);
69
+ const b = Math.sin(timeSeconds * 1.3 + index * 0.21);
70
+ return a * 0.6 + b * 0.4;
71
+ }
72
+
73
+ /** Ambient swing applied to every bar (the faint preview-side breath). */
74
+ const AMBIENT_BASE = 0.035;
75
+ /** Extra ambient swing layered onto the played side (the shimmering wake). */
76
+ const AMBIENT_PLAYED = 0.06;
77
+ /** Bars ahead of the playhead over which the played-side shimmer fades out. */
78
+ const AMBIENT_AHEAD_FADE = 26;
79
+
80
+ /**
81
+ * Ease-out cubic for the head-colour entrance ramp: 0→1 with most of the travel
82
+ * up front, so the colour blooms in quickly then settles. Clamped to [0, 1].
83
+ */
84
+ export function entranceEase(progress: number): number {
85
+ const t = Math.min(1, Math.max(0, progress));
86
+ return 1 - (1 - t) ** 3;
87
+ }
88
+
89
+ /**
90
+ * 0..1 tint strength for a bar's comet-head colour overlay. Peaks at the
91
+ * playhead (via {@link headFocus}) and scales with both the live `energy` (so it
92
+ * fades out on the pause settle) and the `entrance` ramp (so it animates in when
93
+ * playback starts).
94
+ */
95
+ export function headColorBlend(opts: { distance: number; energy: number; entrance: number }): number {
96
+ return headFocus(opts.distance) * opts.energy * opts.entrance;
97
+ }
98
+
99
+ interface LiveAmplitudeOptions {
100
+ basePeak: number;
101
+ index: number;
102
+ /** Fractional bar position of the playhead (played * peaks.length). */
103
+ playheadIndex: number;
104
+ timeSeconds: number;
105
+ reduced: boolean;
106
+ /** 0..1 multiplier on the live boost — 1 while playing, ramps to 0 on the pause settle. */
107
+ energy?: number;
108
+ }
109
+
110
+ /**
111
+ * Final 0..1 bar amplitude, blending the static peak with two live layers: the
112
+ * playhead-localized comet (body swell + bright head spike + bidirectional
113
+ * dance) and a global ambient ripple biased toward the played side. The
114
+ * combined boost is scaled by `energy` (for the pause settle) and zeroed under
115
+ * `reduced` (prefers-reduced-motion).
116
+ */
117
+ export function liveAmplitude(opts: LiveAmplitudeOptions): number {
118
+ const { basePeak, index, playheadIndex, timeSeconds, reduced, energy = 1 } = opts;
119
+ if (reduced || energy <= 0) return basePeak;
120
+
121
+ const distance = index - playheadIndex;
122
+
123
+ // Layer 1 — focal comet near the playhead.
124
+ const env = cometEnvelope(distance);
125
+ const head = headFocus(distance);
126
+ const comet = env * 0.15 + head * 0.2 + env * 0.28 * barWobble(index, timeSeconds);
127
+
128
+ // Layer 2 — global ambient ripple. Full strength behind the playhead (the
129
+ // played wake), fading across AMBIENT_AHEAD_FADE bars into the preview ahead.
130
+ const playedBias = distance <= 0 ? 1 : Math.max(0, 1 - distance / AMBIENT_AHEAD_FADE);
131
+ const ambient = ambientWave(index, timeSeconds) * (AMBIENT_BASE + AMBIENT_PLAYED * playedBias);
132
+
133
+ const amp = basePeak + (comet + ambient) * energy;
134
+
135
+ return Math.min(1, Math.max(0, amp));
136
+ }
@@ -97,8 +97,13 @@ export function renderCaptionRow(opts: CaptionRowOptions): HTMLElement {
97
97
  const row = document.createElement('div');
98
98
  row.className = 'blok-audio-caption-row';
99
99
  row.setAttribute('data-role', 'audio-caption-row');
100
+ // Inner wrapper carries the visible layout (padding/border) so the row itself
101
+ // can collapse cleanly via grid-template-rows when toggled.
102
+ const inner = document.createElement('div');
103
+ inner.className = 'blok-audio-caption-row__inner';
100
104
  const cap = editableLine('audio-caption', opts.value, opts.editable, opts.placeholder);
101
105
  cap.className = 'blok-audio-caption';
102
- row.appendChild(cap);
106
+ inner.appendChild(cap);
107
+ row.appendChild(inner);
103
108
  return row;
104
109
  }