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.