@base44/vite-plugin 1.0.15 → 1.0.17

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@base44/vite-plugin",
3
- "version": "1.0.15",
3
+ "version": "1.0.17",
4
4
  "description": "The Vite plugin for base44 based applications",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -1,27 +1,78 @@
1
+ import { PLUGIN_ELEMENT_ATTR } from "./utils.js";
2
+
3
+ type CanvasWheelPanData = {
4
+ deltaX: number;
5
+ deltaY: number;
6
+ deltaMode: number;
7
+ clientX: number;
8
+ clientY: number;
9
+ shiftKey: boolean;
10
+ ctrlKey: false;
11
+ metaKey: false;
12
+ };
13
+
14
+ function elementFromEventTarget(target: EventTarget | null): Element | null {
15
+ if (target instanceof Element) return target;
16
+ if (target instanceof Node) return target.parentElement;
17
+ return null;
18
+ }
19
+
20
+ function isPluginOwnedTarget(target: EventTarget | null): boolean {
21
+ return elementFromEventTarget(target)?.closest(`[${PLUGIN_ELEMENT_ATTR}]`) != null;
22
+ }
23
+
1
24
  export function createCanvasWheelZoomBridgeController() {
25
+ let isEnabled = false;
26
+
2
27
  const onWheel = (event: WheelEvent): void => {
3
- if (!event.ctrlKey && !event.metaKey) return;
28
+ if (isPluginOwnedTarget(event.target)) return;
4
29
 
5
30
  event.preventDefault();
31
+ if (event.ctrlKey || event.metaKey) {
32
+ window.parent.postMessage({
33
+ type: "canvas-wheel-zoom",
34
+ data: {
35
+ deltaY: event.deltaY,
36
+ deltaMode: event.deltaMode,
37
+ clientX: event.clientX,
38
+ clientY: event.clientY,
39
+ ctrlKey: event.ctrlKey,
40
+ metaKey: event.metaKey,
41
+ },
42
+ }, "*");
43
+ return;
44
+ }
45
+
46
+ const panData: CanvasWheelPanData = {
47
+ deltaX: event.deltaX,
48
+ deltaY: event.deltaY,
49
+ deltaMode: event.deltaMode,
50
+ clientX: event.clientX,
51
+ clientY: event.clientY,
52
+ shiftKey: event.shiftKey,
53
+ ctrlKey: false,
54
+ metaKey: false,
55
+ };
6
56
  window.parent.postMessage({
7
- type: "canvas-wheel-zoom",
8
- data: {
9
- deltaY: event.deltaY,
10
- deltaMode: event.deltaMode,
11
- clientX: event.clientX,
12
- clientY: event.clientY,
13
- ctrlKey: event.ctrlKey,
14
- metaKey: event.metaKey,
15
- },
57
+ type: "canvas-wheel-pan",
58
+ data: panData,
16
59
  }, "*");
17
60
  };
18
61
 
62
+ const enable = (): void => {
63
+ if (isEnabled) return;
64
+ isEnabled = true;
65
+ window.addEventListener("wheel", onWheel, { capture: true, passive: false });
66
+ };
67
+
68
+ const disable = (): void => {
69
+ if (!isEnabled) return;
70
+ isEnabled = false;
71
+ window.removeEventListener("wheel", onWheel, true);
72
+ };
73
+
19
74
  return {
20
- enable: (): void => {
21
- window.addEventListener("wheel", onWheel, { capture: true, passive: false });
22
- },
23
- teardown: (): void => {
24
- window.removeEventListener("wheel", onWheel, true);
25
- },
75
+ enable,
76
+ disable,
26
77
  };
27
78
  }
@@ -3,10 +3,11 @@
3
3
  // fire on their own.
4
4
  //
5
5
  // parent → child { type: "freeze-vh-units", referenceVhBase?: number }
6
- // Rewrites every `vh` in <style> + CSSOM to a CSS variable-backed
7
- // expression (kills the vh auto-resize feedback loop). Repeated calls
8
- // update the variable, so callers can rebase without recovering raw CSS.
9
- // Idempotent; covers HMR-added styles too. Fire-and-forget.
6
+ // Rewrites every viewport-height unit (`vh`/`dvh`/`svh`/`lvh`) in
7
+ // <style> + CSSOM to a CSS variable-backed expression (kills the
8
+ // vh auto-resize feedback loop). Repeated calls update the variable,
9
+ // so callers can rebase without recovering raw CSS. Idempotent; covers
10
+ // HMR-added styles too. Fire-and-forget.
10
11
  //
11
12
  // parent → child { type: "measure-page-height", settleMs?: number }
12
13
  // After `settleMs` (default 2000) posts the page's measured content height
@@ -35,6 +36,19 @@ const FALLBACK_VHBASE: number = 900;
35
36
  const MIN_VHBASE: number = 400;
36
37
  const DEFAULT_SETTLE_MS: number = 2000;
37
38
  const NEUTRALIZE_DEBOUNCE_MS: number = 16;
39
+ // Three consecutive same-height samples ≈ ~50ms of "no change". Three is
40
+ // enough to filter single-frame jitter from layout passes but short enough
41
+ // that responses arrive promptly when the DOM is already settled.
42
+ const STABILITY_SAMPLES: number = 3;
43
+ // Interval between stability samples. ~1 frame at 60Hz; chosen as a plain
44
+ // setTimeout (not rAF) because jsdom rAFs are unreliably timed for tests and
45
+ // in real browsers a 16ms tick is post-layout for any layout work that fits
46
+ // inside one frame.
47
+ const STABILITY_TICK_MS: number = 16;
48
+ // Hard cap on the stability poll past settleMs. Pages that genuinely never
49
+ // stabilize (looping height-animating content) reply with their last sample
50
+ // rather than hanging the parent's resize logic.
51
+ const STABILITY_MAX_WAIT_MS: number = 1500;
38
52
  const REFERENCE_VH_BASE_VAR: string = "--base44-reference-vh-base";
39
53
 
40
54
  const noop: () => void = (): void => {};
@@ -91,9 +105,21 @@ export function setupPageHeightBridge(): () => void {
91
105
  export function createPageHeightBridgeController(): PageHeightBridgeController {
92
106
  let vhCleanups: Array<() => void> | null = null;
93
107
  let vhForceRun: (() => void) | null = null;
94
- let pendingResponse: number | undefined;
108
+ let pendingSettle: number | undefined;
109
+ let pendingTick: number | undefined;
95
110
  let pendingOrigin: string = "*";
96
111
 
112
+ const cancelPending = (): void => {
113
+ if (pendingSettle !== undefined) {
114
+ window.clearTimeout(pendingSettle);
115
+ pendingSettle = undefined;
116
+ }
117
+ if (pendingTick !== undefined) {
118
+ window.clearTimeout(pendingTick);
119
+ pendingTick = undefined;
120
+ }
121
+ };
122
+
97
123
  return {
98
124
  freezeVhUnits: (override: number | undefined): void => {
99
125
  const referenceVhBase: number = resolveReferenceVhBase(override);
@@ -109,14 +135,52 @@ export function createPageHeightBridgeController(): PageHeightBridgeController {
109
135
  // Target the requester's origin so the height isn't broadcast to anyone
110
136
  // who happens to embed us. Falls back to "*" when origin is unavailable
111
137
  // (jsdom default, sandboxed iframes with `null` origin).
138
+ //
139
+ // Stability-wait response: after `settleMs`, poll measureContentHeight at
140
+ // STABILITY_TICK_MS intervals until it's unchanged for STABILITY_SAMPLES
141
+ // consecutive ticks, then send exactly one reply. Catches late React
142
+ // mounts (sticky-positioned sections committing after settleMs), debounced
143
+ // neutralizer mutations not yet flushed, image/font-driven layout shifts.
144
+ // STABILITY_MAX_WAIT_MS caps the poll so a page that genuinely never
145
+ // settles still replies eventually.
112
146
  measurePageHeight: (origin: string, settleMs: number = DEFAULT_SETTLE_MS): void => {
113
147
  pendingOrigin = origin;
114
- if (pendingResponse !== undefined) window.clearTimeout(pendingResponse);
115
- pendingResponse = window.setTimeout((): void => {
116
- requestAnimationFrame((): void => {
117
- const height: number = measureContentHeight();
148
+ cancelPending();
149
+
150
+ pendingSettle = window.setTimeout((): void => {
151
+ pendingSettle = undefined;
152
+ // Flush any debounced unscroll / inline-vh rewrites NOW so the first
153
+ // sample sees a DOM that reflects every mutation triggered during the
154
+ // settle window. Without this, an `overflow-y: scroll` container that
155
+ // mounted mid-settle can still be hiding its children at sample time.
156
+ vhForceRun?.();
157
+
158
+ const deadline: number = nowMs() + STABILITY_MAX_WAIT_MS;
159
+ let lastHeight: number = -1;
160
+ let stableSamples: number = 0;
161
+
162
+ const respond = (height: number): void => {
163
+ pendingTick = undefined;
118
164
  window.parent.postMessage({ type: "page-height-measured", height }, pendingOrigin);
119
- });
165
+ };
166
+
167
+ const tick = (): void => {
168
+ pendingTick = undefined;
169
+ const height: number = measureContentHeight();
170
+ if (height === lastHeight) {
171
+ stableSamples++;
172
+ } else {
173
+ stableSamples = 1;
174
+ lastHeight = height;
175
+ }
176
+ if (stableSamples >= STABILITY_SAMPLES || nowMs() >= deadline) {
177
+ respond(height);
178
+ return;
179
+ }
180
+ pendingTick = window.setTimeout(tick, STABILITY_TICK_MS);
181
+ };
182
+
183
+ tick();
120
184
  }, settleMs);
121
185
  },
122
186
 
@@ -126,11 +190,18 @@ export function createPageHeightBridgeController(): PageHeightBridgeController {
126
190
  vhCleanups = null;
127
191
  vhForceRun = null;
128
192
  }
129
- if (pendingResponse !== undefined) window.clearTimeout(pendingResponse);
193
+ cancelPending();
130
194
  },
131
195
  };
132
196
  }
133
197
 
198
+ function nowMs(): number {
199
+ if (typeof performance !== "undefined" && typeof performance.now === "function") {
200
+ return performance.now();
201
+ }
202
+ return Date.now();
203
+ }
204
+
134
205
  function resolveReferenceVhBase(override: number | undefined): number {
135
206
  if (override !== undefined) return override;
136
207
  const detected: number = window.innerHeight || 0;
@@ -147,18 +218,65 @@ function setReferenceVhBase(referenceVhBase: number): void {
147
218
  // parent requests (idempotent — already-rewritten text is skipped via the
148
219
  // processed sets).
149
220
  function startVhNeutralizer(cleanups: Array<() => void>): () => void {
150
- const VH_RE: RegExp = /(\d+(?:\.\d+)?)vh\b/g;
221
+ // Match vh + the dynamic/small/large variants. Tailwind v4 emits `h-screen`
222
+ // as `100dvh`; all four collapse to the same frozen `referenceVhBase`.
223
+ const VH_RE: RegExp = /(\d+(?:\.\d+)?)(?:d|s|l)?vh\b/g;
151
224
  const processedStyles: WeakSet<HTMLStyleElement> = new WeakSet();
152
225
  const processedSheets: WeakMap<CSSStyleSheet, number> = new WeakMap();
153
226
  const watchedStyles: WeakSet<HTMLStyleElement> = new WeakSet();
154
227
  const styleObservers: Set<MutationObserver> = new Set();
155
228
 
156
- const neutralize = (): void => {
157
- const rewrite = (input: string): string =>
158
- input.replace(VH_RE, (_match: string, n: string): string =>
159
- `calc(var(${REFERENCE_VH_BASE_VAR}) * ${formatVhFactor(n)})`,
160
- );
229
+ const unscrolled: WeakSet<Element> = new WeakSet();
230
+
231
+ const rewrite = (input: string): string =>
232
+ input.replace(VH_RE, (_match: string, n: string): string =>
233
+ `calc(var(${REFERENCE_VH_BASE_VAR}) * ${formatVhFactor(n)})`,
234
+ );
235
+
236
+ // Defeats internal vertical scrollers. The canvas wants the whole page laid
237
+ // out top-to-bottom, but `<div class="overflow-y-auto" style={{height:
238
+ // "100vh"}}>` hides its children behind an internal scrollbar — only the
239
+ // first child appears in the preview. Forcing `overflow-y: visible` lets
240
+ // children paint outside the parent's box; block flow positions them at
241
+ // their natural offsets so document.body extends to include them. `auto`
242
+ // and `scroll` only — `hidden` and `clip` express intentional clipping (UI
243
+ // chrome, rounded-corner masks) we shouldn't undo.
244
+ const unscrollY = (el: Element): void => {
245
+ if (unscrolled.has(el)) return;
246
+ const computedStyle: CSSStyleDeclaration = window.getComputedStyle(el);
247
+ const ovY: string = computedStyle.overflowY;
248
+ if (ovY !== "auto" && ovY !== "scroll") return;
249
+ unscrolled.add(el);
250
+ (el as HTMLElement).style.setProperty("overflow-y", "visible", "important");
251
+ };
252
+
253
+ const unscrollSubtree = (root: Element): void => {
254
+ unscrollY(root);
255
+ root.querySelectorAll<HTMLElement>("*").forEach(unscrollY);
256
+ };
257
+
258
+ // Rewrites vh-bearing properties on a single element's inline style. Covers
259
+ // React's `style={{ height: "100vh" }}` and imperative `el.style.h = ".vh"`
260
+ // — both bypass <style> tags and CSSOM. Per-property setProperty preserves
261
+ // `!important`. Snapshotting prop names first guards against iteration-time
262
+ // mutation of `style.length`.
263
+ const rewriteInlineStyle = (el: Element): void => {
264
+ const style: CSSStyleDeclaration | undefined = (el as HTMLElement).style;
265
+ if (!style || style.length === 0) return;
266
+ const props: string[] = [];
267
+ for (let i: number = 0; i < style.length; i++) {
268
+ const prop: string | undefined = style[i];
269
+ if (prop) props.push(prop);
270
+ }
271
+ for (const prop of props) {
272
+ const value: string = style.getPropertyValue(prop);
273
+ if (!value || value.indexOf("vh") === -1) continue;
274
+ const next: string = rewrite(value);
275
+ if (next !== value) style.setProperty(prop, next, style.getPropertyPriority(prop));
276
+ }
277
+ };
161
278
 
279
+ const neutralize = (): void => {
162
280
  document.querySelectorAll<HTMLStyleElement>("style").forEach((el: HTMLStyleElement): void => {
163
281
  watchStyleEl(el);
164
282
  if (processedStyles.has(el)) return;
@@ -182,6 +300,15 @@ function startVhNeutralizer(cleanups: Array<() => void>): () => void {
182
300
  rewriteVhInRules(rules, rewrite);
183
301
  processedSheets.set(sheet, rules.length);
184
302
  }
303
+
304
+ // Initial sweep of inline `style="...vh..."` attributes already in the DOM
305
+ // at freeze time. Future inline-style mutations (React commits, motion
306
+ // libraries, imperative assignments) are picked up by inlineStyleObserver.
307
+ document.querySelectorAll<HTMLElement>('[style*="vh"]').forEach(rewriteInlineStyle);
308
+
309
+ // Defeat internal scrollers — same dual coverage (initial sweep here,
310
+ // subtree additions in inlineStyleObserver).
311
+ if (document.body) unscrollSubtree(document.body);
185
312
  };
186
313
 
187
314
  const debouncer: Debouncer = createDebouncer(neutralize, NEUTRALIZE_DEBOUNCE_MS);
@@ -231,6 +358,58 @@ function startVhNeutralizer(cleanups: Array<() => void>): () => void {
231
358
  cleanups.push((): void => document.removeEventListener("DOMContentLoaded", attachHeadObserver));
232
359
  }
233
360
 
361
+ // Pass 3: inline `style` attributes. One global observer covers every
362
+ // element — past, present, and future — without per-node tracking. Two
363
+ // mutation kinds matter:
364
+ // • attributes: React/JS sets `style` on an element already in the tree
365
+ // (`el.style.h = "..vh"`, re-render diff). After rewrite the value has
366
+ // no "vh", so the next mutation gates out — single-tick convergence.
367
+ // • childList: a node is mounted with its `style` attribute already set
368
+ // off-tree (React's initial mount path uses createElement+setAttribute
369
+ // BEFORE appendChild, so attribute mutations never fire for them). Scan
370
+ // the added subtree for `[style*="vh"]` and rewrite.
371
+ // Microtask delivery means rewrites land before layout, so no flash.
372
+ const scanSubtreeForInlineVh = (node: Node): void => {
373
+ if (!(node instanceof Element)) return;
374
+ const attr: string | null = node.getAttribute("style");
375
+ if (attr && attr.indexOf("vh") !== -1) rewriteInlineStyle(node);
376
+ node.querySelectorAll<HTMLElement>('[style*="vh"]').forEach(rewriteInlineStyle);
377
+ // New subtrees may introduce overflow-y: auto|scroll containers (route
378
+ // changes, conditional UI). Catch them here too.
379
+ unscrollSubtree(node);
380
+ };
381
+ const inlineStyleObserver: MutationObserver = new MutationObserver(
382
+ (mutations: MutationRecord[]): void => {
383
+ for (const m of mutations) {
384
+ if (m.type === "attributes") {
385
+ const target: Node = m.target;
386
+ if (!(target instanceof Element)) continue;
387
+ const attr: string | null = target.getAttribute("style");
388
+ if (!attr || attr.indexOf("vh") === -1) continue;
389
+ rewriteInlineStyle(target);
390
+ } else if (m.type === "childList") {
391
+ for (let i: number = 0; i < m.addedNodes.length; i++) {
392
+ const node: Node | undefined = m.addedNodes[i];
393
+ if (node) scanSubtreeForInlineVh(node);
394
+ }
395
+ }
396
+ }
397
+ },
398
+ );
399
+ cleanups.push((): void => inlineStyleObserver.disconnect());
400
+
401
+ const attachInlineStyleObserver = (): void => {
402
+ const root: Element | null = document.documentElement;
403
+ if (!root) return;
404
+ inlineStyleObserver.observe(root, {
405
+ attributes: true,
406
+ attributeFilter: ["style"],
407
+ childList: true,
408
+ subtree: true,
409
+ });
410
+ };
411
+ attachInlineStyleObserver();
412
+
234
413
  return debouncedNeutralize;
235
414
  }
236
415
 
@@ -269,13 +448,17 @@ function measureContentHeight(): number {
269
448
  document.documentElement.scrollHeight,
270
449
  document.body?.scrollHeight ?? 0,
271
450
  );
451
+ const referenceVhBase: number = readReferenceVhBase();
452
+ // Excludes `body.clientHeight`: it grows with content, which makes
453
+ // viewportBottom land at the document bottom and trips the stretched-
454
+ // container heuristic on the last section. `referenceVhBase` keeps the
455
+ // h-screen-wrapper case covered when the iframe is shorter than 100vh.
272
456
  const viewportHeight: number = Math.max(
273
457
  window.innerHeight || 0,
274
458
  document.documentElement.clientHeight,
275
- document.body?.clientHeight ?? 0,
459
+ referenceVhBase,
276
460
  );
277
461
  const contentBottom: number = measureElementContentBottom(viewportHeight);
278
- const referenceVhBase: number = readReferenceVhBase();
279
462
  if (contentBottom > 0) return Math.ceil(Math.max(contentBottom, referenceVhBase));
280
463
  return Math.ceil(Math.max(scrollHeight, referenceVhBase));
281
464
  }
@@ -296,10 +479,15 @@ function measureElementContentBottom(viewportHeight: number): number {
296
479
  const el: Element | undefined = elements[i];
297
480
  if (!el) continue;
298
481
  const childContentBottom: number = readChildrenContentBottom(el, contentBottoms);
299
- const rawBottom: number = readElementBottom(el);
300
- const selfBottom: number = isViewportStretchedContainer(rawBottom, childContentBottom, viewportBottom)
482
+ const metrics: ElementMetrics = readElementMetrics(el);
483
+ const selfBottom: number = isViewportStretchedContainer(
484
+ metrics,
485
+ childContentBottom,
486
+ viewportHeight,
487
+ viewportBottom,
488
+ )
301
489
  ? 0
302
- : rawBottom;
490
+ : metrics.bottom;
303
491
  contentBottoms.set(el, Math.max(childContentBottom, selfBottom));
304
492
  }
305
493
 
@@ -319,12 +507,17 @@ function readChildrenContentBottom(
319
507
  return childContentBottom;
320
508
  }
321
509
 
322
- function readElementBottom(el: Element): number {
510
+ type ElementMetrics = { bottom: number; height: number };
511
+
512
+ function readElementMetrics(el: Element): ElementMetrics {
323
513
  const computedStyle: CSSStyleDeclaration = window.getComputedStyle(el);
324
- if (isOutOfFlowDecoration(computedStyle)) return 0;
514
+ if (isOutOfFlowDecoration(computedStyle)) return { bottom: 0, height: 0 };
325
515
  const rect: DOMRect = el.getBoundingClientRect();
326
- if (rect.width === 0 && rect.height === 0) return 0;
327
- return rect.bottom + window.scrollY + readMarginBottom(computedStyle);
516
+ if (rect.width === 0 && rect.height === 0) return { bottom: 0, height: 0 };
517
+ return {
518
+ bottom: rect.bottom + window.scrollY + readMarginBottom(computedStyle),
519
+ height: rect.height,
520
+ };
328
521
  }
329
522
 
330
523
  function readMarginBottom(computedStyle: CSSStyleDeclaration): number {
@@ -337,15 +530,22 @@ function isOutOfFlowDecoration(computedStyle: CSSStyleDeclaration): boolean {
337
530
  return computedStyle.position === "absolute" && computedStyle.pointerEvents === "none";
338
531
  }
339
532
 
533
+ // A "stretched container" spans the full viewport top-to-bottom. Requires
534
+ // BOTH `bottom ≈ viewportBottom` AND `height ≈ viewportHeight` — otherwise an
535
+ // in-flow section that just happens to end at viewportBottom (e.g. when the
536
+ // iframe has been content-sized to the previous measurement) gets falsely
537
+ // filtered, undermeasuring the page.
340
538
  function isViewportStretchedContainer(
341
- elementBottom: number,
539
+ metrics: ElementMetrics,
342
540
  childBottom: number,
541
+ viewportHeight: number,
343
542
  viewportBottom: number,
344
543
  ): boolean {
345
544
  return (
346
545
  childBottom > 0 &&
347
- Math.abs(elementBottom - viewportBottom) <= 1 &&
348
- elementBottom - childBottom > 8
546
+ Math.abs(metrics.bottom - viewportBottom) <= 1 &&
547
+ Math.abs(metrics.height - viewportHeight) <= 1 &&
548
+ metrics.bottom - childBottom > 8
349
549
  );
350
550
  }
351
551
 
@@ -432,6 +432,7 @@ export function setupVisualEditAgent() {
432
432
  isVisualEditMode = isEnabled;
433
433
 
434
434
  if (!isEnabled) {
435
+ canvasWheelZoomBridge.disable();
435
436
  resumeAnimations();
436
437
  inlineEdit.stopEditing();
437
438
  clearSelection();