@wingleeio/mugen-markdown 0.4.2 → 0.4.3

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/index.cjs CHANGED
@@ -2041,10 +2041,16 @@ function renderMarkdown(source, options = {}) {
2041
2041
  * text fading in. Nothing about the row ever animates; layout is done the
2042
2042
  * moment the text lands.
2043
2043
  *
2044
- * Unlike a list-level overlay, the veil canvas lives **inside** the markdown's
2045
- * own box (`position: absolute; inset: 0`), so it scrolls with the content and
2046
- * needs no viewport-geometry tracking. The painter idles (no rAF) until a
2047
- * DOM mutation arrives, so leaving `fade` on for a settled block costs nothing.
2044
+ * The veil canvas lives **inside** the markdown's own box (`position:
2045
+ * absolute`), so it scrolls with the content. It is *windowed to the scroll
2046
+ * viewport* each frame sized and positioned to cover only the visible band,
2047
+ * never the full answer height because the backing store is reallocated and
2048
+ * `clearRect`-cleared every frame, and a full-height canvas on a tall stream
2049
+ * makes that O(answer length): a multi-megapixel clear at 60fps that blows the
2050
+ * frame budget once the answer is tall. The veils only ever sit on the freshly
2051
+ * appended tail, which stick-to-bottom keeps at the viewport's edge, so the
2052
+ * window loses nothing. The painter also idles (no rAF) until a DOM mutation
2053
+ * arrives, so leaving `fade` on for a settled block costs nothing.
2048
2054
  */
2049
2055
  const EMA_SEED_MS = 160;
2050
2056
  const MIN_FADE_MS = 120;
@@ -2059,6 +2065,10 @@ function elementInChrome(el, container) {
2059
2065
  for (let p = el; p != null && p !== container; p = p.parentElement) if (p.tagName === "BUTTON") return true;
2060
2066
  return false;
2061
2067
  }
2068
+ /** Whether a computed `overflow` value clips its overflow (bounds visibility). */
2069
+ function isClipped(overflow) {
2070
+ return overflow === "auto" || overflow === "scroll" || overflow === "hidden" || overflow === "clip" || overflow === "overlay";
2071
+ }
2062
2072
  function contentTextFilter(container) {
2063
2073
  return { acceptNode: (n) => inChrome(n, container) ? NodeFilter.FILTER_REJECT : NodeFilter.FILTER_ACCEPT };
2064
2074
  }
@@ -2147,6 +2157,9 @@ var FadePainter = class {
2147
2157
  raf = 0;
2148
2158
  running = false;
2149
2159
  mo = null;
2160
+ /** Scrollable/clipping ancestors of the content — found once, then cached.
2161
+ * Intersecting their rects bounds the veil canvas to the visible band. */
2162
+ clippers = null;
2150
2163
  attach(content, canvas) {
2151
2164
  if (typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches) return;
2152
2165
  const ctx = canvas.getContext("2d");
@@ -2192,6 +2205,36 @@ var FadePainter = class {
2192
2205
  this.running = true;
2193
2206
  this.raf = requestAnimationFrame(this.frame);
2194
2207
  }
2208
+ /**
2209
+ * The vertical band of `content` currently inside the scroll viewport, in
2210
+ * client coordinates, clamped to the content's own box. Intersects every
2211
+ * scrollable/clipping ancestor (found once via `getComputedStyle`, then
2212
+ * cached) and the window. This is what keeps the veil canvas O(viewport)
2213
+ * instead of O(content height): only the visible band is sized and cleared.
2214
+ */
2215
+ visibleBand(content, rect) {
2216
+ if (this.clippers == null) {
2217
+ const list = [];
2218
+ for (let p = content.parentElement; p != null; p = p.parentElement) {
2219
+ const s = getComputedStyle(p);
2220
+ if (isClipped(s.overflowY) || isClipped(s.overflowX)) list.push(p);
2221
+ }
2222
+ this.clippers = list;
2223
+ }
2224
+ let top = 0;
2225
+ let bottom = typeof innerHeight === "number" ? innerHeight : rect.bottom;
2226
+ for (const c of this.clippers) {
2227
+ const r = c.getBoundingClientRect();
2228
+ if (r.top > top) top = r.top;
2229
+ if (r.bottom < bottom) bottom = r.bottom;
2230
+ }
2231
+ const visTop = Math.max(rect.top, top);
2232
+ const visBottom = Math.min(rect.bottom, bottom);
2233
+ return {
2234
+ top: visTop,
2235
+ height: Math.max(0, visBottom - visTop)
2236
+ };
2237
+ }
2195
2238
  frame = () => {
2196
2239
  const content = this.content;
2197
2240
  const canvas = this.canvas;
@@ -2224,8 +2267,12 @@ var FadePainter = class {
2224
2267
  const boost = 1 + .3 * Math.max(0, this.veils.length - 2);
2225
2268
  this.veils = this.veils.filter((v) => (now - v.t0) * boost < duration);
2226
2269
  const dpr = typeof devicePixelRatio === "number" && devicePixelRatio > 0 ? devicePixelRatio : 1;
2227
- const w = content.clientWidth;
2228
- const h = content.clientHeight;
2270
+ const rect = content.getBoundingClientRect();
2271
+ const band = this.visibleBand(content, rect);
2272
+ const w = rect.width;
2273
+ const h = band.height;
2274
+ canvas.style.top = `${band.top - rect.top}px`;
2275
+ canvas.style.height = `${h}px`;
2229
2276
  if (canvas.width !== Math.round(w * dpr) || canvas.height !== Math.round(h * dpr)) {
2230
2277
  canvas.width = Math.round(w * dpr);
2231
2278
  canvas.height = Math.round(h * dpr);
@@ -2233,7 +2280,10 @@ var FadePainter = class {
2233
2280
  ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
2234
2281
  ctx.clearRect(0, 0, w, h);
2235
2282
  if (this.veils.length > 0) {
2236
- const origin = canvas.getBoundingClientRect();
2283
+ const origin = {
2284
+ left: rect.left,
2285
+ top: band.top
2286
+ };
2237
2287
  const bgCache = /* @__PURE__ */ new Map();
2238
2288
  const groups = /* @__PURE__ */ new Map();
2239
2289
  const minStart = this.veils.reduce((m, v) => Math.min(m, v.start), Infinity);
@@ -2302,9 +2352,10 @@ function renderFadeMarkdown(props) {
2302
2352
  "aria-hidden": true,
2303
2353
  style: {
2304
2354
  position: "absolute",
2305
- inset: 0,
2355
+ left: 0,
2356
+ top: 0,
2306
2357
  width: "100%",
2307
- height: "100%",
2358
+ height: 0,
2308
2359
  pointerEvents: "none"
2309
2360
  }
2310
2361
  }));
package/dist/index.mjs CHANGED
@@ -2040,10 +2040,16 @@ function renderMarkdown(source, options = {}) {
2040
2040
  * text fading in. Nothing about the row ever animates; layout is done the
2041
2041
  * moment the text lands.
2042
2042
  *
2043
- * Unlike a list-level overlay, the veil canvas lives **inside** the markdown's
2044
- * own box (`position: absolute; inset: 0`), so it scrolls with the content and
2045
- * needs no viewport-geometry tracking. The painter idles (no rAF) until a
2046
- * DOM mutation arrives, so leaving `fade` on for a settled block costs nothing.
2043
+ * The veil canvas lives **inside** the markdown's own box (`position:
2044
+ * absolute`), so it scrolls with the content. It is *windowed to the scroll
2045
+ * viewport* each frame sized and positioned to cover only the visible band,
2046
+ * never the full answer height because the backing store is reallocated and
2047
+ * `clearRect`-cleared every frame, and a full-height canvas on a tall stream
2048
+ * makes that O(answer length): a multi-megapixel clear at 60fps that blows the
2049
+ * frame budget once the answer is tall. The veils only ever sit on the freshly
2050
+ * appended tail, which stick-to-bottom keeps at the viewport's edge, so the
2051
+ * window loses nothing. The painter also idles (no rAF) until a DOM mutation
2052
+ * arrives, so leaving `fade` on for a settled block costs nothing.
2047
2053
  */
2048
2054
  const EMA_SEED_MS = 160;
2049
2055
  const MIN_FADE_MS = 120;
@@ -2058,6 +2064,10 @@ function elementInChrome(el, container) {
2058
2064
  for (let p = el; p != null && p !== container; p = p.parentElement) if (p.tagName === "BUTTON") return true;
2059
2065
  return false;
2060
2066
  }
2067
+ /** Whether a computed `overflow` value clips its overflow (bounds visibility). */
2068
+ function isClipped(overflow) {
2069
+ return overflow === "auto" || overflow === "scroll" || overflow === "hidden" || overflow === "clip" || overflow === "overlay";
2070
+ }
2061
2071
  function contentTextFilter(container) {
2062
2072
  return { acceptNode: (n) => inChrome(n, container) ? NodeFilter.FILTER_REJECT : NodeFilter.FILTER_ACCEPT };
2063
2073
  }
@@ -2146,6 +2156,9 @@ var FadePainter = class {
2146
2156
  raf = 0;
2147
2157
  running = false;
2148
2158
  mo = null;
2159
+ /** Scrollable/clipping ancestors of the content — found once, then cached.
2160
+ * Intersecting their rects bounds the veil canvas to the visible band. */
2161
+ clippers = null;
2149
2162
  attach(content, canvas) {
2150
2163
  if (typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches) return;
2151
2164
  const ctx = canvas.getContext("2d");
@@ -2191,6 +2204,36 @@ var FadePainter = class {
2191
2204
  this.running = true;
2192
2205
  this.raf = requestAnimationFrame(this.frame);
2193
2206
  }
2207
+ /**
2208
+ * The vertical band of `content` currently inside the scroll viewport, in
2209
+ * client coordinates, clamped to the content's own box. Intersects every
2210
+ * scrollable/clipping ancestor (found once via `getComputedStyle`, then
2211
+ * cached) and the window. This is what keeps the veil canvas O(viewport)
2212
+ * instead of O(content height): only the visible band is sized and cleared.
2213
+ */
2214
+ visibleBand(content, rect) {
2215
+ if (this.clippers == null) {
2216
+ const list = [];
2217
+ for (let p = content.parentElement; p != null; p = p.parentElement) {
2218
+ const s = getComputedStyle(p);
2219
+ if (isClipped(s.overflowY) || isClipped(s.overflowX)) list.push(p);
2220
+ }
2221
+ this.clippers = list;
2222
+ }
2223
+ let top = 0;
2224
+ let bottom = typeof innerHeight === "number" ? innerHeight : rect.bottom;
2225
+ for (const c of this.clippers) {
2226
+ const r = c.getBoundingClientRect();
2227
+ if (r.top > top) top = r.top;
2228
+ if (r.bottom < bottom) bottom = r.bottom;
2229
+ }
2230
+ const visTop = Math.max(rect.top, top);
2231
+ const visBottom = Math.min(rect.bottom, bottom);
2232
+ return {
2233
+ top: visTop,
2234
+ height: Math.max(0, visBottom - visTop)
2235
+ };
2236
+ }
2194
2237
  frame = () => {
2195
2238
  const content = this.content;
2196
2239
  const canvas = this.canvas;
@@ -2223,8 +2266,12 @@ var FadePainter = class {
2223
2266
  const boost = 1 + .3 * Math.max(0, this.veils.length - 2);
2224
2267
  this.veils = this.veils.filter((v) => (now - v.t0) * boost < duration);
2225
2268
  const dpr = typeof devicePixelRatio === "number" && devicePixelRatio > 0 ? devicePixelRatio : 1;
2226
- const w = content.clientWidth;
2227
- const h = content.clientHeight;
2269
+ const rect = content.getBoundingClientRect();
2270
+ const band = this.visibleBand(content, rect);
2271
+ const w = rect.width;
2272
+ const h = band.height;
2273
+ canvas.style.top = `${band.top - rect.top}px`;
2274
+ canvas.style.height = `${h}px`;
2228
2275
  if (canvas.width !== Math.round(w * dpr) || canvas.height !== Math.round(h * dpr)) {
2229
2276
  canvas.width = Math.round(w * dpr);
2230
2277
  canvas.height = Math.round(h * dpr);
@@ -2232,7 +2279,10 @@ var FadePainter = class {
2232
2279
  ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
2233
2280
  ctx.clearRect(0, 0, w, h);
2234
2281
  if (this.veils.length > 0) {
2235
- const origin = canvas.getBoundingClientRect();
2282
+ const origin = {
2283
+ left: rect.left,
2284
+ top: band.top
2285
+ };
2236
2286
  const bgCache = /* @__PURE__ */ new Map();
2237
2287
  const groups = /* @__PURE__ */ new Map();
2238
2288
  const minStart = this.veils.reduce((m, v) => Math.min(m, v.start), Infinity);
@@ -2301,9 +2351,10 @@ function renderFadeMarkdown(props) {
2301
2351
  "aria-hidden": true,
2302
2352
  style: {
2303
2353
  position: "absolute",
2304
- inset: 0,
2354
+ left: 0,
2355
+ top: 0,
2305
2356
  width: "100%",
2306
- height: "100%",
2357
+ height: 0,
2307
2358
  pointerEvents: "none"
2308
2359
  }
2309
2360
  }));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wingleeio/mugen-markdown",
3
- "version": "0.4.2",
3
+ "version": "0.4.3",
4
4
  "description": "Measurable markdown for mugen — incremark-parsed, rendered with mugen primitives so the virtualizer's tree walker computes exact row heights.",
5
5
  "license": "MIT",
6
6
  "author": "Wing Lee <contact@winglee.io>",
@@ -65,7 +65,7 @@
65
65
  "tsdown": "^0.22.2",
66
66
  "typescript": "^6.0.3",
67
67
  "vitest": "^4.1.8",
68
- "@wingleeio/mugen": "0.3.5"
68
+ "@wingleeio/mugen": "0.3.6"
69
69
  },
70
70
  "scripts": {
71
71
  "build": "tsdown",