@jackuait/blok 0.18.0 → 0.19.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/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-BosfCfPX.cjs`),t=require(`./chunks/constants-ChsAFZ8F.cjs`),n=require(`./chunks/objectSpread2-iNkE6aJh.cjs`),r=require(`./chunks/tools-DmEzyrn3.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-CXB-exXU.mjs";
2
+ import { Pi as n } from "./chunks/constants-BmCvHbkL.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-BdoYHyV0.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-BosfCfPX.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-CXB-exXU.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-ChsAFZ8F.cjs`),t=require(`./chunks/tools-DmEzyrn3.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-BmCvHbkL.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-BdoYHyV0.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.0",
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';
@@ -141,6 +141,38 @@ class Blok {
141
141
  },
142
142
  };
143
143
 
144
+ /**
145
+ * Width API — expose it on the instance immediately (mirrors the theme API),
146
+ * so host apps can call instance.width.set('full') before isReady without a
147
+ * silent no-op. The chosen mode is buffered and replayed once UI is ready.
148
+ */
149
+ const widthBuffer = { pendingMode: null as EditorWidth | null };
150
+
151
+ const getUIModule = (): BlokModules['UI'] | undefined =>
152
+ (blok.moduleInstances as Partial<BlokModules>).UI;
153
+
154
+ const readWidthMode = (): EditorWidth => {
155
+ const ui = getUIModule();
156
+
157
+ return ui !== undefined ? ui.getWidthMode() : (widthBuffer.pendingMode ?? 'narrow');
158
+ };
159
+
160
+ const applyWidthMode = (mode: EditorWidth): void => {
161
+ widthBuffer.pendingMode = mode;
162
+
163
+ const ui = getUIModule();
164
+
165
+ if (ui !== undefined) {
166
+ ui.setWidthMode(mode);
167
+ }
168
+ };
169
+
170
+ (this as Record<string, unknown>).width = {
171
+ get: (): EditorWidth => readWidthMode(),
172
+ set: (mode: EditorWidth): void => applyWidthMode(mode),
173
+ toggle: (): void => applyWidthMode(readWidthMode() === 'full' ? 'narrow' : 'full'),
174
+ };
175
+
144
176
  /**
145
177
  * We need to export isReady promise in the constructor
146
178
  * as it can be used before other API methods are exported
@@ -203,6 +235,16 @@ class Blok {
203
235
  themeBuffer.pendingMode = null;
204
236
  }
205
237
 
238
+ // Apply any width mode buffered before isReady resolved.
239
+ if (widthBuffer.pendingMode !== null) {
240
+ const ui = (blok.moduleInstances as Partial<BlokModules>).UI;
241
+
242
+ if (ui !== undefined) {
243
+ ui.setWidthMode(widthBuffer.pendingMode);
244
+ }
245
+ widthBuffer.pendingMode = null;
246
+ }
247
+
206
248
  // Scroll to the block referenced by the URL hash, if present.
207
249
  // isReady resolves only after all blocks are in the DOM (requestIdleCallback fence in Renderer),
208
250
  // so no extra polling is needed even on slow connections.
@@ -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%;
@@ -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
@@ -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
+ }
@@ -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,
@@ -212,6 +215,7 @@ export class Blok {
212
215
 
213
216
  public blocks: Blocks;
214
217
  public caret: Caret;
218
+ public history: History;
215
219
  public sanitizer: Sanitizer;
216
220
  public saver: Saver;
217
221
  public selection: Selection;
@@ -221,6 +225,7 @@ export class Blok {
221
225
  public tooltip: Tooltip;
222
226
  public readOnly: ReadOnly;
223
227
  public theme: Theme;
228
+ public width: Width;
224
229
  constructor(configuration?: BlokConfig|string);
225
230
 
226
231
  /**