@base44/vite-plugin 1.0.18 → 1.0.20

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.18",
3
+ "version": "1.0.20",
4
4
  "description": "The Vite plugin for base44 based applications",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/index.ts CHANGED
@@ -57,6 +57,13 @@ export default function vitePlugin(
57
57
  strictPort: true,
58
58
  // Allow all hosts - essential for Modal tunnel URLs
59
59
  allowedHosts: true,
60
+ // Route the HMR WebSocket through the preview proxy instead of
61
+ // the sandbox origin. When BASE44_HMR_HOST is unset (e.g. before
62
+ // the proxy is deployed), fall back to Vite's default behavior
63
+ // and connect to window.location.host.
64
+ ...(process.env.BASE44_HMR_HOST
65
+ ? { hmr: { host: process.env.BASE44_HMR_HOST } }
66
+ : {}),
60
67
  watch: {
61
68
  // Enable polling for better file change detection in containers
62
69
  usePolling: true,
@@ -0,0 +1,271 @@
1
+ // auto-popup-suppressor — hides auto-opening modal popups in the canvas
2
+ // preview iframe so the page snapshot isn't obscured by overlays the user
3
+ // didn't intend to capture. Driven by `{ type: "suppress-auto-popups" }` from
4
+ // the parent; inert until that arrives.
5
+ //
6
+ // Two-stage heuristic:
7
+ //
8
+ // 1. BACKDROP MATCH — element must be `position: fixed`, cover ≥90% of the
9
+ // viewport on both axes, have a popup-level z-index, and show backdrop-
10
+ // like styles (translucent background OR backdrop-filter). This is the
11
+ // modal-overlay fingerprint.
12
+ //
13
+ // 2. PANEL SWEEP — once we've hidden any backdrop, sweep the whole document
14
+ // for fixed/high-z elements whose bbox falls in a "modal-shaped" band
15
+ // (15–95% on both axes). Picks up the dialog content panel even when it
16
+ // isn't a direct sibling of the overlay (Radix wraps content in
17
+ // FocusScope + DismissableLayer + focus-guards; shadcn's portals use
18
+ // `asChild`, so structural relationships vary).
19
+ //
20
+ // A sticky `backdropEverDetected` flag keeps the panel-shape check live for
21
+ // later MutationObserver ticks — Content sometimes mounts in a different
22
+ // batch than its Overlay.
23
+ //
24
+ // Why the percentage gates aren't based on window.innerHeight: the canvas
25
+ // iframe is auto-sized to the document content (e.g. 4958px), so innerHeight
26
+ // is the document height, not the visible viewport. page-height-bridge sets
27
+ // `--base44-reference-vh-base` to the canvas tile height — the viewport the
28
+ // user actually sees — and that's what our gates use.
29
+
30
+ const SUPPRESSED_ATTR = "data-base44-suppressed-popup";
31
+ const REFERENCE_VH_BASE_VAR = "--base44-reference-vh-base";
32
+
33
+ const FULL_VIEWPORT_RATIO = 0.9;
34
+ const MODAL_PANEL_MIN_RATIO = 0.15;
35
+ const MODAL_PANEL_MAX_RATIO = 0.95;
36
+ const POPUP_Z_INDEX_THRESHOLD = 40;
37
+
38
+ type Viewport = { vw: number; vh: number };
39
+
40
+ export type AutoPopupSuppressorController = {
41
+ suppress: () => void;
42
+ teardown: () => void;
43
+ };
44
+
45
+ // ── Public API ──────────────────────────────────────────────────────────────
46
+
47
+ export function createAutoPopupSuppressorController(): AutoPopupSuppressorController {
48
+ let observer: MutationObserver | null = null;
49
+ let domReadyHandler: (() => void) | null = null;
50
+ let active = false;
51
+ // Once a backdrop has been seen, modal-shaped panels appearing in later
52
+ // mutation batches are also hidden. Without this, a Content node that
53
+ // mounts after its Overlay's batch wouldn't be matched in-flight.
54
+ let backdropEverDetected = false;
55
+
56
+ // Single decision per element. One getComputedStyle call — backdrop and
57
+ // panel checks share the position/z-index preconditions. Without sharing,
58
+ // every non-matching node in a React commit's added subtree would pay for
59
+ // two style recalcs, which is the MutationObserver hot path.
60
+ const classify = (el: Element, vp: Viewport): void => {
61
+ if (!(el instanceof HTMLElement)) return;
62
+ if (el.hasAttribute(SUPPRESSED_ATTR)) return;
63
+ const style = window.getComputedStyle(el);
64
+ if (style.position !== "fixed") return;
65
+ if (!hasPopupZIndex(style)) return;
66
+ if (coversFullViewport(el, vp) && hasBackdropLook(el, style)) {
67
+ hideBackdrop(el);
68
+ return;
69
+ }
70
+ if (backdropEverDetected && hasModalShapeBbox(el, vp)) {
71
+ applyHide(el);
72
+ }
73
+ };
74
+
75
+ const hideBackdrop = (el: Element): void => {
76
+ if (el.hasAttribute(SUPPRESSED_ATTR)) return;
77
+ applyHide(el);
78
+ backdropEverDetected = true;
79
+ sweepDocumentForModalPanels(viewportForGates());
80
+ };
81
+
82
+ const scanSubtree = (root: Element, vp: Viewport): void => {
83
+ classify(root, vp);
84
+ const descendants = root.querySelectorAll<HTMLElement>(
85
+ `*:not([${SUPPRESSED_ATTR}])`,
86
+ );
87
+ for (let i = 0; i < descendants.length; i++) {
88
+ const el = descendants[i];
89
+ if (el) classify(el, vp);
90
+ }
91
+ };
92
+
93
+ const attachObserver = (): void => {
94
+ if (observer) return;
95
+ // viewportForGates is read once per mutation batch, not per added node —
96
+ // a busy React commit can deliver dozens of added subtrees in one tick.
97
+ observer = new MutationObserver((mutations) => {
98
+ const vp = viewportForGates();
99
+ for (const m of mutations) {
100
+ if (m.type !== "childList") continue;
101
+ for (let i = 0; i < m.addedNodes.length; i++) {
102
+ const node = m.addedNodes[i];
103
+ if (node instanceof Element) scanSubtree(node, vp);
104
+ }
105
+ }
106
+ });
107
+ observer.observe(document.documentElement, { childList: true, subtree: true });
108
+ };
109
+
110
+ const start = (): void => {
111
+ if (!document.body) return;
112
+ scanSubtree(document.body, viewportForGates());
113
+ attachObserver();
114
+ };
115
+
116
+ return {
117
+ suppress: (): void => {
118
+ if (active) return;
119
+ active = true;
120
+ if (document.body) {
121
+ start();
122
+ } else {
123
+ domReadyHandler = (): void => {
124
+ domReadyHandler = null;
125
+ start();
126
+ };
127
+ document.addEventListener("DOMContentLoaded", domReadyHandler);
128
+ }
129
+ },
130
+ teardown: (): void => {
131
+ active = false;
132
+ backdropEverDetected = false;
133
+ if (observer) {
134
+ observer.disconnect();
135
+ observer = null;
136
+ }
137
+ if (domReadyHandler) {
138
+ document.removeEventListener("DOMContentLoaded", domReadyHandler);
139
+ domReadyHandler = null;
140
+ }
141
+ },
142
+ };
143
+ }
144
+
145
+ // ── Document-wide panel sweep ───────────────────────────────────────────────
146
+
147
+ // Triggered once per backdrop hide. Walks the whole document for unhidden
148
+ // fixed/high-z elements whose bbox falls inside the modal-shape band — picks
149
+ // up content panels regardless of how they're nested or which portal they
150
+ // went through.
151
+ function sweepDocumentForModalPanels(vp: Viewport): void {
152
+ if (!document.body) return;
153
+ if (vp.vw <= 0 || vp.vh <= 0) return;
154
+ const candidates = document.body.querySelectorAll<HTMLElement>(
155
+ `*:not([${SUPPRESSED_ATTR}])`,
156
+ );
157
+ for (let i = 0; i < candidates.length; i++) {
158
+ const el = candidates[i];
159
+ if (el && isModalPanel(el, vp)) applyHide(el);
160
+ }
161
+ }
162
+
163
+ function isModalPanel(el: HTMLElement, vp: Viewport): boolean {
164
+ if (el.hasAttribute(SUPPRESSED_ATTR)) return false;
165
+ const style = window.getComputedStyle(el);
166
+ if (style.position !== "fixed") return false;
167
+ if (!hasPopupZIndex(style)) return false;
168
+ return hasModalShapeBbox(el, vp);
169
+ }
170
+
171
+ // ── Gate predicates ─────────────────────────────────────────────────────────
172
+
173
+ function coversFullViewport(el: HTMLElement, vp: Viewport): boolean {
174
+ if (vp.vw <= 0 || vp.vh <= 0) return false;
175
+ const rect = el.getBoundingClientRect();
176
+ return (
177
+ rect.width >= vp.vw * FULL_VIEWPORT_RATIO &&
178
+ rect.height >= vp.vh * FULL_VIEWPORT_RATIO
179
+ );
180
+ }
181
+
182
+ // "Modal-shaped" = small enough not to be a sidebar/full-screen overlay
183
+ // (≤95% on both axes), big enough not to be a nav/footer/toast (≥15% on both
184
+ // axes). Calibrated against shadcn Dialog content (≈25–50% × 30–50%), top
185
+ // nav (height fails ≤15%), sidebar (height fails ≥95%), toast (height fails
186
+ // ≤15%).
187
+ function hasModalShapeBbox(el: HTMLElement, vp: Viewport): boolean {
188
+ const rect = el.getBoundingClientRect();
189
+ return (
190
+ rect.width >= vp.vw * MODAL_PANEL_MIN_RATIO &&
191
+ rect.height >= vp.vh * MODAL_PANEL_MIN_RATIO &&
192
+ rect.width <= vp.vw * MODAL_PANEL_MAX_RATIO &&
193
+ rect.height <= vp.vh * MODAL_PANEL_MAX_RATIO
194
+ );
195
+ }
196
+
197
+ function hasPopupZIndex(style: CSSStyleDeclaration): boolean {
198
+ const z = parseInt(style.zIndex, 10);
199
+ return Number.isFinite(z) && z >= POPUP_Z_INDEX_THRESHOLD;
200
+ }
201
+
202
+ // True if the element looks like a modal backdrop: translucent fill OR a
203
+ // backdrop-filter (e.g. blur). Also returns true if the element's own bg is
204
+ // transparent but a fixed/high-z descendant paints the backdrop — covers
205
+ // dialog wrappers that delegate the backdrop to a child element.
206
+ function hasBackdropLook(el: HTMLElement, style: CSSStyleDeclaration): boolean {
207
+ if (isTranslucent(style.backgroundColor)) return true;
208
+ if (style.backdropFilter && style.backdropFilter !== "none") return true;
209
+ const descendants = el.querySelectorAll<HTMLElement>("*");
210
+ for (let i = 0; i < descendants.length; i++) {
211
+ const child = descendants[i];
212
+ if (!child) continue;
213
+ const cs = window.getComputedStyle(child);
214
+ if (cs.position !== "fixed" && cs.position !== "absolute") continue;
215
+ if (!hasPopupZIndex(cs)) continue;
216
+ if (isTranslucent(cs.backgroundColor)) return true;
217
+ if (cs.backdropFilter && cs.backdropFilter !== "none") return true;
218
+ }
219
+ return false;
220
+ }
221
+
222
+ function isTranslucent(color: string): boolean {
223
+ const alpha = parseAlpha(color);
224
+ return alpha !== null && alpha > 0 && alpha < 1;
225
+ }
226
+
227
+ // Extracts the alpha channel from a computed background-color across legacy
228
+ // AND modern color syntaxes:
229
+ //
230
+ // • `rgb(r, g, b)` / `rgba(r, g, b, a)` — legacy comma syntax.
231
+ // • `<func>(... / a)` / `<func>(... / a%)` — modern slash-alpha (oklch,
232
+ // oklab, lch, lab, hsl, hsla, color, modern rgb). Tailwind v4 / shadcn
233
+ // theme tokens compute to these.
234
+ // • A recognized color function call without explicit alpha → 1.
235
+ //
236
+ // Returns null when unrecognized; callers treat null as "not a backdrop".
237
+ function parseAlpha(value: string): number | null {
238
+ if (!value) return null;
239
+ const comma = /^rgba?\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*(?:,\s*([0-9.]+)\s*)?\)$/.exec(value);
240
+ if (comma) return comma[1] === undefined ? 1 : parseFloat(comma[1]);
241
+ const slash = /\/\s*([0-9.]+)(%?)\s*\)$/.exec(value);
242
+ if (slash) {
243
+ const n = parseFloat(slash[1]!);
244
+ return slash[2] === "%" ? n / 100 : n;
245
+ }
246
+ if (/^[a-z]+\s*\(/.test(value)) return 1;
247
+ return null;
248
+ }
249
+
250
+ // ── Viewport + DOM ops ──────────────────────────────────────────────────────
251
+
252
+ // Viewport dimensions used for percentage gates. Width comes from
253
+ // `innerWidth` (unaffected by canvas auto-fit). Height prefers the reference
254
+ // vh set by page-height-bridge — without that, after the iframe auto-resizes
255
+ // to document content, `innerHeight` is the document height and every modal
256
+ // panel falls below the 15% floor.
257
+ function viewportForGates(): Viewport {
258
+ const vw = window.innerWidth || document.documentElement.clientWidth || 0;
259
+ const refStr = document.documentElement.style.getPropertyValue(REFERENCE_VH_BASE_VAR);
260
+ const ref = parseFloat(refStr);
261
+ const vh = Number.isFinite(ref) && ref > 0
262
+ ? ref
263
+ : (window.innerHeight || document.documentElement.clientHeight || 0);
264
+ return { vw, vh };
265
+ }
266
+
267
+ function applyHide(el: Element): void {
268
+ if (el.hasAttribute(SUPPRESSED_ATTR)) return;
269
+ el.setAttribute(SUPPRESSED_ATTR, "1");
270
+ (el as HTMLElement).style.setProperty("display", "none", "important");
271
+ }
@@ -5,12 +5,14 @@ import { createInlineEditController } from "../capabilities/inline-edit/index.js
5
5
  import { THEME_FONT_PREVIEW_ID } from "../consts.js";
6
6
  import { createCanvasWheelZoomBridgeController } from "./canvas-wheel-zoom-bridge.js";
7
7
  import { createPageHeightBridgeController } from "./page-height-bridge.js";
8
+ import { createAutoPopupSuppressorController } from "./auto-popup-suppressor.js";
8
9
 
9
10
  const REPOSITION_DELAY_MS = 50;
10
11
 
11
12
  export function setupVisualEditAgent() {
12
13
  const canvasWheelZoomBridge = createCanvasWheelZoomBridgeController();
13
14
  const pageHeightBridge = createPageHeightBridgeController();
15
+ const autoPopupSuppressor = createAutoPopupSuppressorController();
14
16
 
15
17
  // State variables (replacing React useState/useRef)
16
18
  let isVisualEditMode = false;
@@ -659,6 +661,10 @@ export function setupVisualEditAgent() {
659
661
  );
660
662
  break;
661
663
 
664
+ case "suppress-auto-popups":
665
+ autoPopupSuppressor.suppress();
666
+ break;
667
+
662
668
  case "toggle-canvas-wheel-zoom-bridge":
663
669
  canvasWheelZoomBridge.enable();
664
670
  break;