PageSourceSearch

https://smartcone.ai/src/shell/shared/settingsPopup.js

js smartcone.ai collected 2026-10-05 15:06:02 UTC 19,382 bytes, 625 lines download raw bytes

1// /src/shell/shared/settingsPopup.js
2// Settings popup for container format controls (Grid, Guides, Snap, etc.)
3
4import { buildGradient, buildBorder, buildShadow } from "../../controls/index.js";
5
6function clamp(v, a, b) { return Math.max(a, Math.min(b, v)); }
7
8function centerPopupOnContainer(popup, containerEl) {
9  const cr = containerEl.getBoundingClientRect();
10  const pr = popup.getBoundingClientRect();
11  
12  // Position near the top-right of the container
13  let left = cr.right - pr.width - 20;
14  let top = cr.top + 60;
15  
16  const margin = 8;
17  left = clamp(left, margin, window.innerWidth - pr.width - margin);
18  top = clamp(top, margin, window.innerHeight - pr.height - margin);
19  
20  popup.style.position = "fixed";
21  popup.style.left = `${left}px`;
22  popup.style.top = `${top}px`;
23}
24
25function makePopupDraggable(popup, handle) {
26  popup.addEventListener("pointerdown", () => {
27    const allPopups = document.querySelectorAll(".popup");
28    let maxZ = 9999;
29    allPopups.forEach(p => {
30      if (p !== popup) {
31        const z = parseInt(p.style.zIndex) || 0;
32        if (z > maxZ) maxZ = z;
33      }
34    });
35    popup.style.zIndex = String(maxZ + 1);
36  });
37  let sx, sy, ox, oy, dragging = false;
38  handle.addEventListener("pointerdown", (e) => {
39    if (e.target.closest(".popup-close")) return;
40    dragging = true;
41    sx = e.clientX; sy = e.clientY;
42    const r = popup.getBoundingClientRect();
43    ox = r.left; oy = r.top;
44    handle.setPointerCapture?.(e.pointerId);
45    
46    const move = (evt) => {
47      if (!dragging) return;
48      popup.style.left = ox + (evt.clientX - sx) + "px";
49      popup.style.top = oy + (evt.clientY - sy) + "px";
50    };
51    
52    const up = () => {
53      dragging = false;
54      handle.releasePointerCapture?.(e.pointerId);
55      window.removeEventListener("pointermove", move);
56      window.removeEventListener("pointerup", up);
57    };
58    
59    window.addEventListener("pointermove", move);
60    window.addEventListener("pointerup", up);
61  });
62}
63
64/**
65 * Opens a settings popup for a container with format controls
66 * @param {HTMLElement} containerEl - The container element
67 * @param {HTMLElement} bodyEl - The container body element
68 * @param {Object} api - The container's API object
69 */
70export function openSettingsPopup(containerEl, bodyEl, api) {
71  // Close any existing settings popup
72  const existing = document.querySelector(".settings-popup");
73  if (existing) existing.remove();
74  
75  const popup = document.createElement("div");
76  popup.className = "popup settings-popup";
77  popup.style.zIndex = "10000";
78  
79  const header = document.createElement("div");
80  header.className = "popup-header";
81  
82  const headerTitle = document.createElement("span");
83  headerTitle.textContent = "Settings";
84  header.appendChild(headerTitle);
85  
86  const closeBtn = document.createElement("div");
87  closeBtn.className = "popup-close";
88  closeBtn.textContent = "✕";
89  header.appendChild(closeBtn);
90  
91  const body = document.createElement("div");
92  body.className = "popup-body";
93  body.style.cssText = "display: block; padding: 12px; max-height: 75vh; overflow-y: auto; flex: 1;";
94  
95  // Refresh function to update values
96  function refresh() {
97    setTimeout(() => openSettingsPopup(containerEl, bodyEl, api), 0);
98  }
99  
100  // Build settings items based on container type
101  const type = containerEl.dataset.mode;
102  const containerType = containerEl.dataset.type; // e.g., "zimxform" or "container:grid"
103  const settings = [];
104  
105  // Layout Mode setting (for zimxforms only)
106  if (containerType === "zimxform" || containerType === "container:zimxform") {
107    const currentMode = containerEl.dataset.mode || "absolute";
108    settings.push({
109      icon: "fas fa-layer-group",
110      label: "Layout Mode",
111      value: currentMode.toUpperCase(),
112      dropdown: true,
113      options: ["absolute", "grid", "flow", "rails", "free"],
114      currentIndex: ["absolute", "grid", "flow", "rails", "free"].indexOf(currentMode),
115      onChange: (newIndex) => {
116        const modes = ["absolute", "grid", "flow", "rails", "free"];
117        const newMode = modes[newIndex] || "absolute";
118        api?.update?.({ mode: newMode });
119        refresh();
120      }
121    });
122  }
123  
124  // Columns setting (for grid, bsgrid, form)
125  if (["grid", "bsgrid", "form"].includes(type)) {
126    const cols = parseInt(containerEl.dataset.columns, 10) || 12;
127    settings.push({
128      icon: "fas fa-th-large",
129      label: "Columns",
130      value: cols,
131      onChange: (delta) => {
132        const newCols = Math.max(1, cols + delta);
133        api?.update?.({ columns: newCols });
134        refresh();
135      }
136    });
137  }
138  
139  // Gap setting (for most container types)
140  if (type !== "list") {
141    const gap = parseInt(containerEl.dataset.gap, 10) || 8;
142    settings.push({
143      icon: "fas fa-grip-lines",
144      label: "Gap",
145      value: gap,
146      unit: "px",
147      onChange: (delta) => {
148        const newGap = Math.max(0, gap + delta);
149        api?.update?.({ gap: newGap });
150        refresh();
151      }
152    });
153  }
154  
155  // Padding setting (for most container types)
156  if (type !== "list") {
157    const padding = parseInt(containerEl.dataset.padding, 10) || 8;
158    settings.push({
159      icon: "fas fa-expand-alt",
160      label: "Padding",
161      value: padding,
162      unit: "px",
163      onChange: (delta) => {
164        const newPadding = Math.max(0, padding + delta);
165        api?.update?.({ padding: newPadding });
166        refresh();
167      }
168    });
169  }
170
171  // List-specific settings (Item Height, Selectable, Multiselect)
172  if (type === "list") {
173    const itemHeight = parseInt(containerEl.dataset.itemHeight, 10) || 36;
174    settings.push({
175      icon: "fas fa-ruler-vertical",
176      label: "Item Height",
177      value: itemHeight,
178      unit: "px",
179      onChange: (delta) => {
180        const newH = Math.max(24, itemHeight + delta * 4);
181        containerEl.dataset.itemHeight = String(newH);
182        api?.update?.({ itemHeight: newH });
183        refresh();
184      }
185    });
186
187    const selectable = (containerEl.dataset.selectable ?? "false") === "true";
188    settings.push({
189      icon: "fas fa-mouse-pointer",
190      label: "Selectable",
191      value: selectable ? "ON" : "OFF",
192      toggle: true,
193      onChange: () => {
194        containerEl.dataset.selectable = String(!selectable);
195        api?.update?.({ selectable: !selectable });
196        refresh();
197      }
198    });
199
200    const multiselect = (containerEl.dataset.multiselect ?? "false") === "true";
201    settings.push({
202      icon: "fas fa-check-square",
203      label: "Multiselect",
204      value: multiselect ? "ON" : "OFF",
205      toggle: true,
206      onChange: () => {
207        containerEl.dataset.multiselect = String(!multiselect);
208        api?.update?.({ multiselect: !multiselect });
209        refresh();
210      }
211    });
212  }
213  
214  // Flow setting (for flow containers)
215  if (type === "flow") {
216    const flow = containerEl.dataset.flow || "column";
217    settings.push({
218      icon: "fas fa-layer-group",
219      label: "Flow",
220      value: flow,
221      toggle: true,
222      onChange: () => {
223        const newFlow = flow === "row" ? "column" : "row";
224        api?.update?.({ flow: newFlow });
225        refresh();
226      }
227    });
228  }
229  
230  // Guides setting (for grid, form)
231  if (["grid", "form"].includes(type)) {
232    const guidesOff = bodyEl.style.backgroundImage === "none";
233    settings.push({
234      icon: "fas fa-grip-vertical",
235      label: "Guides",
236      value: guidesOff ? "OFF" : "ON",
237      toggle: true,
238      onChange: () => {
239        const isOff = bodyEl.style.backgroundImage === "none";
240        bodyEl.style.backgroundImage = isOff ? "" : "none";
241        refresh();
242      }
243    });
244  }
245  
246  // Snap setting (for stickies)
247  if (type === "stickies") {
248    const customActions = containerEl.__stickies_actions;
249    
250    // New sticky button
251    settings.push({
252      icon: "fas fa-plus",
253      label: "New Sticky",
254      value: "ADD",
255      action: true,
256      onChange: () => {
257        if (customActions?.add) {
258          customActions.add();
259        }
260        // Close popup after adding
261        setTimeout(() => {
262          const popup = document.querySelector(".settings-popup");
263          if (popup) popup.remove();
264        }, 100);
265      }
266    });
267    
268    const snap = (containerEl.dataset.snap ?? "true").toLowerCase() === "true";
269    settings.push({
270      icon: "fas fa-magnet",
271      label: "Snap",
272      value: snap ? "ON" : "OFF",
273      toggle: true,
274      onChange: () => {
275        if (customActions?.toggleSnap) {
276          customActions.toggleSnap();
277        } else {
278          containerEl.dataset.snap = String(!snap);
279        }
280        refresh();
281      }
282    });
283    
284    // Grid size for stickies
285    const gridSize = parseInt(containerEl.dataset.grid, 10) || 10;
286    settings.push({
287      icon: "fas fa-th",
288      label: "Grid Size",
289      value: gridSize,
290      unit: "px",
291      onChange: (delta) => {
292        const newSize = Math.max(2, Math.min(64, gridSize + delta * 2));
293        if (customActions?.bumpGrid) {
294          customActions.bumpGrid(newSize - gridSize);
295        } else {
296          containerEl.dataset.grid = String(newSize);
297        }
298        refresh();
299      }
300    });
301  }
302
303  // Repeat Rows and Columns settings (for zimxrepeatingform)
304  if (containerType === "zimxrepeatingform") {
305    const repeatRows = parseInt(containerEl.dataset.repeatRows, 10) || 2;
306    settings.push({
307      icon: "fas fa-grip-horizontal",
308      label: "Repeat Rows",
309      value: repeatRows,
310      onChange: (delta) => {
311        const newRows = Math.max(1, Math.min(10, repeatRows + delta));
312        api?.update?.({ repeatRows: newRows });
313        refresh();
314      }
315    });
316
317    const repeatCols = parseInt(containerEl.dataset.repeatCols, 10) || 2;
318    settings.push({
319      icon: "fas fa-grip-vertical",
320      label: "Repeat Columns",
321      value: repeatCols,
322      onChange: (delta) => {
323        const newCols = Math.max(1, Math.min(10, repeatCols + delta));
324        api?.update?.({ repeatCols: newCols });
325        refresh();
326      }
327    });
328  }
329
330  // Render settings items
331  settings.forEach((setting) => {
332    const item = document.createElement("div");
333    item.className = "popup-item settings-item";
334    
335    const icon = document.createElement("i");
336    icon.className = setting.icon;
337    icon.setAttribute("aria-hidden", "true");
338    
339    const label = document.createElement("div");
340    label.className = "settings-label";
341    label.textContent = setting.label;
342    
343    const value = document.createElement("div");
344    value.className = "settings-value";
345    value.textContent = setting.value + (setting.unit || "");
346    
347    item.appendChild(icon);
348    item.appendChild(label);
349    item.appendChild(value);
350    
351    if (setting.action) {
352      // Action button (like "New Sticky")
353      item.addEventListener("click", () => {
354        setting.onChange();
355        // Note: doCloseHandler will be defined later, onChange is responsible for closing if needed
356      });
357      item.style.cursor = "pointer";
358      item.classList.add("settings-action");
359    } else if (setting.dropdown) {
360      // Dropdown selector (cycle through options on click)
361      item.addEventListener("click", () => {
362        const nextIndex = ((setting.currentIndex || 0) + 1) % setting.options.length;
363        setting.onChange(nextIndex);
364      });
365      item.style.cursor = "pointer";
366      item.classList.add("settings-dropdown");
367    } else if (setting.toggle) {
368      // Toggle button
369      item.addEventListener("click", () => {
370        setting.onChange();
371      });
372      item.style.cursor = "pointer";
373    } else {
374      // Plus/minus buttons
375      const controls = document.createElement("div");
376      controls.className = "settings-controls";
377      
378      const minusBtn = document.createElement("button");
379      minusBtn.className = "settings-btn";
380      minusBtn.textContent = "−";
381      minusBtn.onclick = (e) => {
382        e.stopPropagation();
383        setting.onChange(-1);
384      };
385      
386      const plusBtn = document.createElement("button");
387      plusBtn.className = "settings-btn";
388      plusBtn.textContent = "+";
389      plusBtn.onclick = (e) => {
390        e.stopPropagation();
391        setting.onChange(1);
392      };
393      
394      controls.appendChild(minusBtn);
395      controls.appendChild(plusBtn);
396      item.appendChild(controls);
397    }
398    
399    body.appendChild(item);
400  });
401
402  // Background color section (for all container types)
403  const bgSection = document.createElement("div");
404
405  // Parse existing background style
406  let bgStyle = { type: "gradient", color1: "#667eea", color2: "#764ba2", angle: 135 };
407  if (containerEl.dataset.backgroundStyle) {
408    try {
409      bgStyle = JSON.parse(containerEl.dataset.backgroundStyle);
410    } catch (e) {
411      console.warn("Failed to parse backgroundStyle", e);
412    }
413  }
414
415  // Stop drag propagation for interactive elements
416  const stopDragInside = (el) => {
417    ["pointerdown", "mousedown", "touchstart"].forEach((evt) =>
418      el.addEventListener(evt, (e) => e.stopPropagation(), { capture: true })
419    );
420  };
421
422  const gradientPicker = buildGradient({
423    label: "Title Bar Background",
424    type: bgStyle.type || "gradient",
425    color1: bgStyle.color1 || "#667eea",
426    color2: bgStyle.color2 || "#764ba2",
427    angle: bgStyle.angle || 135,
428    stopDragInside,
429    onChange: (value) => {
430      // Update container background in real-time
431      containerEl.dataset.backgroundStyle = JSON.stringify(value);
432
433      // Apply to title bar (container or shell)
434      const titleBar = containerEl.querySelector('.container-title, .form-title-bar, .shell-titlebar');
435      if (titleBar) {
436        if (value.type === "solid") {
437          titleBar.style.background = value.color1;
438        } else {
439          titleBar.style.background = `linear-gradient(${value.angle}deg, ${value.color1} 0%, ${value.color2} 100%)`;
440        }
441      }
442    }
443  });
444
445  bgSection.appendChild(gradientPicker);
446
447  // Body background color section
448  const bodyBgSection = document.createElement("div");
449
450  // Parse existing body background style
451  let bodyBgStyle = { type: "solid", color1: "#ffffff", color2: "#f0f0f0", angle: 135 };
452  if (containerEl.dataset.bodyBackgroundStyle) {
453    try {
454      bodyBgStyle = JSON.parse(containerEl.dataset.bodyBackgroundStyle);
455    } catch (e) {
456      console.warn("Failed to parse bodyBackgroundStyle", e);
457    }
458  }
459
460  const bodyGradientPicker = buildGradient({
461    label: "Body Background",
462    type: bodyBgStyle.type || "solid",
463    color1: bodyBgStyle.color1 || "#ffffff",
464    color2: bodyBgStyle.color2 || "#f0f0f0",
465    angle: bodyBgStyle.angle || 135,
466    stopDragInside,
467    onChange: (value) => {
468      // Update container body background in real-time
469      containerEl.dataset.bodyBackgroundStyle = JSON.stringify(value);
470
471      // Apply to container body or shell content
472      const containerBody = containerEl.querySelector('.container-body, .shell-content');
473      if (containerBody) {
474        if (value.type === "solid") {
475          containerBody.style.background = value.color1;
476        } else {
477          containerBody.style.background = `linear-gradient(${value.angle}deg, ${value.color1} 0%, ${value.color2} 100%)`;
478        }
479      }
480    }
481  });
482
483  bodyBgSection.appendChild(bodyGradientPicker);
484
485  // Border style section
486  const borderSection = document.createElement("div");
487
488  // Parse existing border style
489  let borderStyle = { width: 1, color: "#d1d5db", style: "solid", radius: 8 };
490  if (containerEl.dataset.borderStyle) {
491    try {
492      borderStyle = JSON.parse(containerEl.dataset.borderStyle);
493    } catch (e) {
494      console.warn("Failed to parse borderStyle", e);
495    }
496  }
497
498  const borderPicker = buildBorder({
499    label: "Border Style",
500    width: borderStyle.width ?? 1,
501    color: borderStyle.color || "#d1d5db",
502    style: borderStyle.style || "solid",
503    radius: borderStyle.radius ?? 8,
504    stopDragInside,
505    onChange: (value) => {
506      // Update container border in real-time
507      containerEl.dataset.borderStyle = JSON.stringify(value);
508
509      // Apply to container element
510      if (value.style === "none" || value.width === 0) {
511        containerEl.style.border = "none";
512      } else {
513        containerEl.style.border = `${value.width}px ${value.style} ${value.color}`;
514      }
515      containerEl.style.borderRadius = `${value.radius}px`;
516    }
517  });
518
519  borderSection.appendChild(borderPicker);
520
521  // Shadow style section
522  const shadowSection = document.createElement("div");
523
524  // Parse existing shadow style
525  let shadowStyle = { depth: 4, angle: 135, blur: 8, spread: 0, color: "#00000026" };
526  if (containerEl.dataset.shadowStyle) {
527    try {
528      shadowStyle = JSON.parse(containerEl.dataset.shadowStyle);
529    } catch (e) {
530      console.warn("Failed to parse shadowStyle", e);
531    }
532  }
533
534  const shadowPicker = buildShadow({
535    label: "Shadow Style",
536    depth: shadowStyle.depth ?? 4,
537    angle: shadowStyle.angle ?? 135,
538    blur: shadowStyle.blur ?? 8,
539    spread: shadowStyle.spread ?? 0,
540    color: shadowStyle.color || "#00000026",
541    stopDragInside,
542    onChange: (value) => {
543      // Update container shadow in real-time
544      containerEl.dataset.shadowStyle = JSON.stringify(value);
545
546      // Apply to container element
547      const cssValue = buildShadow({ ...value }).getCSSValue();
548      containerEl.style.boxShadow = cssValue;
549    }
550  });
551
552  shadowSection.appendChild(shadowPicker);
553
554  // Arrange all 4 format pickers in a 2-column grid to reduce popup height
555  const pickersGrid = document.createElement("div");
556  pickersGrid.className = "settings-popup-pickers";
557  pickersGrid.style.cssText = `
558    display: grid;
559    grid-template-columns: 1fr 1fr;
560    gap: 12px;
561    border-top: 1px solid #e5e7eb;
562    margin-top: 12px;
563    padding-top: 12px;
564  `;
565  pickersGrid.appendChild(bgSection);
566  pickersGrid.appendChild(bodyBgSection);
567  pickersGrid.appendChild(borderSection);
568  pickersGrid.appendChild(shadowSection);
569  body.appendChild(pickersGrid);
570
571  popup.appendChild(header);
572  popup.appendChild(body);
573  document.body.appendChild(popup);
574  
575  centerPopupOnContainer(popup, containerEl);
576  makePopupDraggable(popup, header);
577  
578  // Close handlers - define doClose before it's used in refresh
579  closeBtn.setAttribute("role", "button");
580  closeBtn.setAttribute("tabindex", "0");
581  closeBtn.setAttribute("aria-label", "Close");
582  closeBtn.addEventListener("pointerdown", (e) => e.stopPropagation());
583  
584  const doClose = () => {
585    if (popup && popup.parentNode) popup.remove();
586    window.removeEventListener("keydown", onEsc, true);
587  };
588  
589  const onEsc = (e) => {
590    if (e.key === "Escape") {
591      e.stopPropagation();
592      e.preventDefault();
593      doClose();
594    }
595  };
596  
597  window.addEventListener("keydown", onEsc, true);
598  closeBtn.addEventListener("click", (e) => {
599    e.stopPropagation();
600    doClose();
601  });
602  
603  closeBtn.addEventListener("keydown", (e) => {
604    if (e.key === "Enter" || e.key === " ") {
605      e.preventDefault();
606      doClose();
607    }
608  });
609  
610  const onResize = () => {
611    if (document.body.contains(popup)) {
612      centerPopupOnContainer(popup, containerEl);
613    }
614  };
615  
616  window.addEventListener("resize", onResize);
617  
618  const obs = new MutationObserver(() => {
619    if (!document.body.contains(popup)) {
620      window.removeEventListener("resize", onResize);
621      obs.disconnect();
622    }
623  });
624  obs.observe(document.body, { childList: true });
625}

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.