From 57269770b5ac82f9c9f642138ba3f9721d06e40b Mon Sep 17 00:00:00 2001 From: Mark Veidemanis Date: Fri, 13 Mar 2026 16:48:24 +0000 Subject: [PATCH] Rebuild workspace widgets and behavioral graph views --- .gitignore | 2 + app/urls.py | 5 - core/static/css/compose-panel.css | 93 +- core/static/css/gia-theme.css | 90 +- core/static/js/compose-panel-thread.js | 96 ++ core/static/js/compose-panel.js | 21 + core/static/js/workspace-shell.js | 771 +++++++++++++++- core/templates/base.html | 45 +- core/templates/index.html | 4 +- core/templates/mixins/wm/widget.html | 18 +- .../pages/ai-workspace-information.html | 98 +-- .../pages/ai-workspace-insight-detail.html | 112 +-- .../pages/ai-workspace-insight-graphs.html | 129 +-- .../pages/ai-workspace-insight-help.html | 61 +- core/templates/partials/ai-insight-nav.html | 6 +- .../ai-workspace-behavioral-graph-detail.html | 36 + .../ai-workspace-behavioral-graphs.html | 36 + .../ai-workspace-behavioral-help.html | 33 + .../ai-workspace-behavioral-information.html | 51 ++ .../partials/ai-workspace-person-widget.html | 30 +- .../partials/behavioral-graph-card.html | 71 ++ .../partials/behavioral-graph-launcher.html | 71 ++ .../partials/behavioral-range-tabs.html | 56 ++ .../partials/behavioral-summary-card.html | 18 + .../partials/bulma-send-composer.html | 4 +- .../partials/compose-message-row.html | 13 +- .../partials/compose-panel-assets.html | 10 +- core/templates/partials/compose-panel.html | 8 +- .../compose-workspace-contact-results.html | 1 + .../compose-workspace-history-results.html | 1 + core/templates/partials/osint/list-table.html | 2 +- .../templates/partials/signal-chats-list.html | 1 + .../partials/whatsapp-chats-list.html | 1 + .../partials/whatsapp-contacts-list.html | 1 + core/tests/test_behavioral_graph_views.py | 73 ++ core/tests/test_compose_send_capabilities.py | 121 ++- core/tests/test_osint_widget_actions.py | 33 + core/views/compose.py | 581 ++----------- core/views/osint.py | 7 +- core/views/signal.py | 14 + core/views/whatsapp.py | 5 + core/views/workspace.py | 288 ++++-- core/widget_ids.py | 28 + core/workspace/__init__.py | 24 +- core/workspace/behavioral.py | 823 ++++++++++++++++++ mixins/templates/mixins/wm/widget.html | 18 +- .../mixins/templates/mixins/wm/widget.html | 18 +- 47 files changed, 2951 insertions(+), 1077 deletions(-) create mode 100644 core/templates/partials/ai-workspace-behavioral-graph-detail.html create mode 100644 core/templates/partials/ai-workspace-behavioral-graphs.html create mode 100644 core/templates/partials/ai-workspace-behavioral-help.html create mode 100644 core/templates/partials/ai-workspace-behavioral-information.html create mode 100644 core/templates/partials/behavioral-graph-card.html create mode 100644 core/templates/partials/behavioral-graph-launcher.html create mode 100644 core/templates/partials/behavioral-range-tabs.html create mode 100644 core/templates/partials/behavioral-summary-card.html create mode 100644 core/tests/test_behavioral_graph_views.py create mode 100644 core/tests/test_osint_widget_actions.py create mode 100644 core/widget_ids.py create mode 100644 core/workspace/behavioral.py diff --git a/.gitignore b/.gitignore index 2e1a749..eb1541a 100644 --- a/.gitignore +++ b/.gitignore @@ -170,5 +170,7 @@ node_modules/ .codex-cli/ .ship-safe/ .uwsgi-reload +mixins/static/ +vendor/django-crud-mixins/mixins/static/ .container-home/ diff --git a/app/urls.py b/app/urls.py index 4107a12..0d2103a 100644 --- a/app/urls.py +++ b/app/urls.py @@ -342,11 +342,6 @@ urlpatterns = [ compose.ComposeSummary.as_view(), name="compose_summary", ), - path( - "compose/quick-insights/", - compose.ComposeQuickInsights.as_view(), - name="compose_quick_insights", - ), path( "compose/engage/preview/", compose.ComposeEngagePreview.as_view(), diff --git a/core/static/css/compose-panel.css b/core/static/css/compose-panel.css index 65d3526..3a21b19 100644 --- a/core/static/css/compose-panel.css +++ b/core/static/css/compose-panel.css @@ -96,12 +96,12 @@ .compose-shell .compose-thread { display: flex; flex-direction: column; - gap: 0.75rem; + gap: 0.35rem; flex: 1 1 auto; min-height: 0; max-height: none; overflow-y: auto; - padding: 0.75rem; + padding: 0.5rem 0.625rem; border: 1px solid var(--bulma-border, #dbdbdb); background: var(--bulma-scheme-main-bis, #f7f8fa); } @@ -112,10 +112,19 @@ font-size: 0.875rem; } +.compose-shell .compose-history-loader { + margin: 0; + text-align: center; +} + +.compose-shell .compose-history-loader.is-hidden { + display: none; +} + .compose-shell .compose-row { display: flex; flex-direction: column; - gap: 0.35rem; + gap: 0.15rem; } .compose-shell .compose-row.is-in { @@ -137,10 +146,10 @@ .compose-shell .compose-bubble { width: fit-content; - max-width: min(42rem, 100%); - padding: 0.75rem 0.875rem; + max-width: min(38rem, 100%); + padding: 0.4rem 0.55rem; border: 1px solid var(--bulma-border, #dbdbdb); - border-radius: 1rem; + border-radius: 0.8rem; background: var(--bulma-scheme-main, #fff); } @@ -150,8 +159,8 @@ } .compose-shell .compose-reply-ref { - margin-bottom: 0.5rem; - padding-left: 0.75rem; + margin-bottom: 0.3rem; + padding-left: 0.45rem; border-left: 3px solid var(--bulma-border, #dbdbdb); } @@ -160,7 +169,7 @@ border: 0; background: transparent; color: var(--bulma-link, #3273dc); - font-size: 0.75rem; + font-size: 0.6875rem; text-align: left; } @@ -168,35 +177,26 @@ text-decoration: underline; } -.compose-shell .compose-source-badge-wrap { - margin-bottom: 0.5rem; -} - -.compose-shell .compose-source-badge { - font-size: 0.6875rem; - font-weight: 700; - letter-spacing: 0.02em; -} - .compose-shell .compose-media { - margin: 0 0 0.5rem; + margin: 0 0 0.3rem; } .compose-shell .compose-media:last-of-type { - margin-bottom: 0.625rem; + margin-bottom: 0.35rem; } .compose-shell .compose-image { display: block; max-width: min(26rem, 100%); - max-height: 24rem; - border-radius: 0.75rem; + max-height: 22rem; + border-radius: 0.6rem; } .compose-shell .compose-body { margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; + line-height: 1.28; } .compose-shell .compose-image-fallback.is-hidden { @@ -209,11 +209,11 @@ .compose-shell .compose-reactions + .compose-msg-meta, .compose-shell .compose-edit-history + .compose-reactions, .compose-shell .compose-edit-history + .compose-msg-meta { - margin-top: 0.5rem; + margin-top: 0.3rem; } .compose-shell .compose-edit-history { - font-size: 0.75rem; + font-size: 0.6875rem; } .compose-shell .compose-edit-history summary { @@ -221,15 +221,15 @@ } .compose-shell .compose-edit-history ul { - margin: 0.5rem 0 0; + margin: 0.35rem 0 0; padding-left: 1rem; } .compose-shell .compose-edit-diff { display: flex; flex-wrap: wrap; - gap: 0.35rem; - margin-top: 0.25rem; + gap: 0.25rem; + margin-top: 0.15rem; } .compose-shell .compose-edit-old { @@ -244,21 +244,21 @@ .compose-shell .compose-reactions { display: flex; flex-wrap: wrap; - gap: 0.35rem; + gap: 0.2rem; } .compose-shell .compose-reaction-chip { - min-height: 1.7rem; - font-size: 0.875rem; + min-height: 1.45rem; + font-size: 0.75rem; } .compose-shell .compose-msg-meta { display: flex; flex-wrap: wrap; align-items: center; - gap: 0.35rem; - margin-top: 0.5rem; - font-size: 0.75rem; + gap: 0.25rem; + margin-top: 0.3rem; + font-size: 0.6875rem; } .compose-shell .compose-msg-flag { @@ -275,7 +275,30 @@ } .compose-shell .compose-reply-btn { - margin-top: 0.5rem; + display: inline-flex; + align-items: center; + gap: 0.25rem; + margin-top: 0.25rem; + min-height: 1.75rem; + padding-inline: 0.45rem; +} + +@media (hover: hover) { + .compose-shell .compose-row .compose-reply-btn { + opacity: 0; + visibility: hidden; + pointer-events: none; + transition: opacity 0.12s ease-in-out; + } + + .compose-shell .compose-row:hover .compose-reply-btn, + .compose-shell .compose-row.compose-reply-selected .compose-reply-btn, + .compose-shell .compose-row:focus-within .compose-reply-btn, + .compose-shell .compose-reply-btn:focus-visible { + opacity: 1; + visibility: visible; + pointer-events: auto; + } } .compose-shell .compose-form { diff --git a/core/static/css/gia-theme.css b/core/static/css/gia-theme.css index 06b09a4..f403020 100644 --- a/core/static/css/gia-theme.css +++ b/core/static/css/gia-theme.css @@ -130,6 +130,70 @@ body .has-text-grey-light { color: var(--gia-text); } +.gia-split-dropdown .dropdown-menu { + min-width: 18rem; +} + +.gia-dropdown-nest { + border-top: 1px solid var(--gia-border); +} + +.gia-dropdown-nest summary { + list-style: none; + cursor: pointer; +} + +.gia-dropdown-nest summary::-webkit-details-marker { + display: none; +} + +.gia-dropdown-nest-body { + padding: 0.25rem 0 0.35rem; +} + +.gia-dropdown-nest-body .dropdown-item { + padding-left: 1.5rem; +} + +.gia-inline-tabs ul { + border-bottom: 0; +} + +.gia-behavior-shell { + min-width: 0; +} + +.gia-behavior-summary-card, +.gia-behavior-graph-card { + height: 100%; +} + +.gia-behavior-chart-shell { + min-height: 11rem; +} + +.gia-behavior-chart { + display: block; + width: 100%; + height: 11rem; +} + +.gia-behavior-chart-area { + fill: color-mix(in srgb, var(--bulma-link) 16%, transparent); +} + +.gia-behavior-chart-line { + fill: none; + stroke: var(--bulma-link); + stroke-width: 1.6; + stroke-linecap: round; + stroke-linejoin: round; +} + +.gia-behavior-chart-point { + fill: var(--bulma-link); +} + .input, .textarea, .select select { @@ -554,6 +618,16 @@ html.gia-has-workspace-root { box-shadow: 0 0 0 2px rgba(50, 115, 220, 0.16); } +.grid-stack-item.is-gia-anchor .gia-widget-panel { + border-color: rgba(255, 159, 28, 0.55); + box-shadow: 0 0 0 2px rgba(255, 159, 28, 0.12); +} + +.grid-stack-item.is-gia-spawned .gia-widget-panel { + border-color: rgba(72, 199, 142, 0.6); + box-shadow: 0 0 0 3px rgba(72, 199, 142, 0.18); +} + .floating-window { max-height: 300px; z-index: 9000; @@ -740,10 +814,11 @@ html.gia-has-workspace-root { .gia-send-composer { margin: 0; - padding: 0.75rem; - border: 1px solid var(--bulma-border, #dbdbdb); - border-radius: 0.875rem; - background: var(--bulma-scheme-main-bis, #f7f8fa); + padding: 0; + border: 0; + border-radius: 0; + background: transparent; + box-shadow: none; } .gia-send-composer-row { @@ -756,11 +831,12 @@ html.gia-has-workspace-root { } .gia-send-composer-input { - min-height: 2.75rem; - max-height: 8rem; + min-height: 2.5rem; + max-height: 7rem; resize: none; border-top-right-radius: 0; border-bottom-right-radius: 0; + box-shadow: none; } .gia-send-composer-action { @@ -769,7 +845,7 @@ html.gia-has-workspace-root { .gia-send-composer-button { height: 100%; - min-height: 2.75rem; + min-height: 2.5rem; border-top-left-radius: 0; border-bottom-left-radius: 0; } diff --git a/core/static/js/compose-panel-thread.js b/core/static/js/compose-panel-thread.js index 343ef56..88edb7d 100644 --- a/core/static/js/compose-panel-thread.js +++ b/core/static/js/compose-panel-thread.js @@ -18,6 +18,7 @@ const replyBanner = config.replyBanner; const replyBannerText = config.replyBannerText; const replyClearBtn = config.replyClearBtn; + const historyLoader = config.historyLoader; const platformSelect = config.platformSelect; const contactSelect = config.contactSelect; const hiddenService = config.hiddenService; @@ -29,6 +30,9 @@ let lastTs = core.toInt(thread.dataset.lastTs); let beforeContextReset = null; + state.loadingOlder = false; + state.olderExhausted = false; + const nearBottom = function () { return thread.scrollHeight - thread.scrollTop - thread.clientHeight < 120; }; @@ -39,6 +43,21 @@ } }; + const setHistoryLoader = function (message, hidden) { + if (!historyLoader) { + return; + } + historyLoader.textContent = String( + message || "Scroll up to load older messages." + ); + historyLoader.classList.toggle("is-hidden", !!hidden); + }; + + const getOldestTs = function () { + const firstRow = thread.querySelector(".compose-row"); + return core.toInt(firstRow && firstRow.dataset ? firstRow.dataset.ts : 0); + }; + const queryParams = function (extraParams) { const params = new URLSearchParams(); params.set("service", thread.dataset.service || ""); @@ -204,8 +223,28 @@ }); if (rows.length) { scrollToBottom(shouldStick); + setHistoryLoader("", false); } ensureEmptyState(); + if (!thread.querySelector(".compose-row")) { + setHistoryLoader("", true); + } + }; + + const prependMessageHtml = function (html) { + const rows = parseMessageRows(html); + if (!rows.length) { + return 0; + } + const previousHeight = thread.scrollHeight; + const previousTop = thread.scrollTop; + rows.forEach(function (msg) { + upsertMessageRow(msg); + }); + thread.scrollTop = previousTop + (thread.scrollHeight - previousHeight); + setHistoryLoader("", false); + ensureEmptyState(); + return rows.length; }; const applyTyping = function (payload) { @@ -267,6 +306,47 @@ } }; + const loadOlder = async function () { + if (state.loadingOlder || state.olderExhausted) { + return; + } + const oldestTs = getOldestTs(); + if (!oldestTs) { + state.olderExhausted = true; + setHistoryLoader("Start of conversation.", false); + return; + } + state.loadingOlder = true; + setHistoryLoader("Loading older messages...", false); + try { + const response = await fetch( + thread.dataset.pollUrl + "?" + queryParams({ before_ts: String(oldestTs) }), + { + method: "GET", + credentials: "same-origin", + headers: { Accept: "application/json" }, + } + ); + if (!response.ok) { + setHistoryLoader("Could not load older messages.", false); + return; + } + const payload = await response.json(); + const inserted = prependMessageHtml(payload.messages_html || ""); + state.olderExhausted = !payload.has_older || inserted === 0; + setHistoryLoader( + state.olderExhausted + ? "Start of conversation." + : "Scroll up to load older messages.", + false + ); + } catch (_err) { + setHistoryLoader("Could not load older messages.", false); + } finally { + state.loadingOlder = false; + } + }; + const setupWebSocket = function () { const wsPath = String(thread.dataset.wsUrl || "").trim(); if (!wsPath || !window.WebSocket) { @@ -359,8 +439,14 @@ clearReplyTarget(); closeSocket(); lastTs = 0; + state.loadingOlder = false; + state.olderExhausted = false; thread.dataset.lastTs = "0"; thread.innerHTML = ""; + if (historyLoader) { + thread.appendChild(historyLoader); + } + setHistoryLoader("Loading recent messages...", false); ensureEmptyState("Loading messages..."); applyTyping({ typing: false }); poll(true); @@ -466,6 +552,12 @@ setReplyTarget(row.dataset.messageId || "", row.dataset.replySnippet || ""); textarea.focus(); }); + + thread.addEventListener("scroll", function () { + if (thread.scrollTop <= 48) { + loadOlder(); + } + }); }; const init = function () { @@ -473,6 +565,7 @@ bindContextSelectors(); applyTyping(core.parseJsonSafe(panel.dataset.initialTyping || "{}", {})); ensureEmptyState(); + setHistoryLoader("", !thread.querySelector(".compose-row")); scrollToBottom(true); setupWebSocket(); @@ -492,6 +585,9 @@ init: init, poll: poll, queryParams: queryParams, + scrollToLatest: function () { + scrollToBottom(true); + }, setBeforeContextReset: function (callback) { beforeContextReset = callback; }, diff --git a/core/static/js/compose-panel.js b/core/static/js/compose-panel.js index 2d1e596..2785a9b 100644 --- a/core/static/js/compose-panel.js +++ b/core/static/js/compose-panel.js @@ -119,6 +119,7 @@ const threadController = threadModule.createController({ contactSelect: document.getElementById(panelId + "-contact-select"), hiddenIdentifier: document.getElementById(panelId + "-input-identifier"), + historyLoader: document.getElementById(panelId + "-history-loader"), hiddenPerson: document.getElementById(panelId + "-input-person"), hiddenReplyTo: form.querySelector('input[name="reply_to_message_id"]'), hiddenService: document.getElementById(panelId + "-input-service"), @@ -135,6 +136,7 @@ thread: thread, typingNode: document.getElementById(panelId + "-typing"), }); + state.threadController = threadController; const sendController = sendModule.createController({ armInput: form.querySelector('input[name="failsafe_arm"]'), @@ -177,6 +179,25 @@ }, initAll: initAll, initPanel: initPanel, + scrollWidgetToLatest: function (widgetId) { + const widgetNode = widgetId ? document.getElementById(String(widgetId)) : null; + if (!widgetNode) { + return; + } + const panel = widgetNode.querySelector("[data-compose-panel]"); + const panelId = String(panel && panel.id ? panel.id : "").trim(); + if (!panelId) { + return; + } + const state = window.giaComposePanels[panelId]; + if ( + state + && state.threadController + && typeof state.threadController.scrollToLatest === "function" + ) { + state.threadController.scrollToLatest(); + } + }, }; document.addEventListener("DOMContentLoaded", function () { diff --git a/core/static/js/workspace-shell.js b/core/static/js/workspace-shell.js index 4339fc0..a384a8a 100644 --- a/core/static/js/workspace-shell.js +++ b/core/static/js/workspace-shell.js @@ -1,6 +1,7 @@ (function () { const WIDGET_SHELL_SELECTOR = ".js-gia-widget-shell"; const WIDGET_SPAWN_SELECTOR = ".js-widget-spawn-trigger"; + const WIDGET_LOAD_TARGET_SELECTOR = '[hx-target="#widgets-here"], [data-widget-url]'; const WIDGET_ACTION_SELECTOR = ".js-gia-widget-action"; const TASKBAR_SELECTOR = "#gia-taskbar"; const TASKBAR_ITEMS_SELECTOR = "#gia-taskbar-items"; @@ -18,13 +19,22 @@ styles: {}, scripts: {}, }; + let widgetShellProcessingChain = Promise.resolve(); const workspaceState = { order: [], minimized: new Map(), + pendingWidgetIds: new Set(), activeWidgetId: "", + anchorWidgetId: "", + pendingSpawnSourceId: "", + pendingSpawnTs: 0, + lastSpawnedId: "", snapLeftId: "", snapRightId: "", + snapTopId: "", + snapBottomId: "", snapAssistantSourceId: "", + snapAssistantMode: "", }; function withDocumentRoot(root) { @@ -76,6 +86,25 @@ return id ? document.getElementById(id) : null; } + function writeGridPositionAttrs(widgetNode, layoutItem) { + if (!widgetNode || !layoutItem) { + return; + } + widgetNode.setAttribute("gs-id", String(widgetNode.id || "")); + ["x", "y", "w", "h"].forEach(function (key) { + const value = Number(layoutItem[key]); + if (!Number.isFinite(value)) { + return; + } + const attr = "gs-" + key; + if ((key === "w" || key === "h") && value <= 1) { + widgetNode.removeAttribute(attr); + return; + } + widgetNode.setAttribute(attr, String(value)); + }); + } + function syncKnownWidgetOrder() { const ordered = workspaceState.order.filter(function (widgetId, index, items) { return !!widgetId && items.indexOf(widgetId) === index && hasWidget(widgetId); @@ -108,6 +137,23 @@ return !!getWidgetNode(widgetId); } + function getAnchorWidgetId(visibleIds) { + const ids = Array.isArray(visibleIds) && visibleIds.length + ? visibleIds.map(function (value) { + return String(value || "").trim(); + }) + : getVisibleWidgetIds(); + const explicitAnchorId = String(workspaceState.anchorWidgetId || "").trim(); + if ( + explicitAnchorId + && ids.includes(explicitAnchorId) + && !workspaceState.minimized.has(explicitAnchorId) + ) { + return explicitAnchorId; + } + return ""; + } + function executeWidgetScript(scriptNode) { if (!scriptNode) { return; @@ -252,8 +298,106 @@ return value || "fa-solid fa-window-maximize"; } + function getWidgetMeta(widgetId) { + const node = getWidgetNode(widgetId); + const minimizedMeta = workspaceState.minimized.get(widgetId) || null; + return { + id: widgetId, + node: node, + minimized: workspaceState.minimized.has(widgetId), + title: minimizedMeta && minimizedMeta.title + ? String(minimizedMeta.title) + : getWidgetTitle(node), + iconClass: minimizedMeta && minimizedMeta.iconClass + ? String(minimizedMeta.iconClass) + : getWidgetIconClass(node), + }; + } + + function normalizeComposeWidgetSegment(value) { + const cleaned = String(value || "") + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); + return cleaned || "none"; + } + + function buildComposeWidgetIdFromUrl(urlValue) { + const raw = String(urlValue || "").trim(); + if (!raw) { + return ""; + } + try { + const parsed = new URL(raw, window.location.origin); + if (!/\/compose\/widget\/?$/.test(parsed.pathname)) { + return ""; + } + const service = normalizeComposeWidgetSegment(parsed.searchParams.get("service")); + const identifier = normalizeComposeWidgetSegment( + String(parsed.searchParams.get("identifier") || "").split("@", 1)[0] + ); + const person = normalizeComposeWidgetSegment(parsed.searchParams.get("person")); + return ["widget", "compose-widget", service, identifier, person].join("-"); + } catch (_err) { + return ""; + } + } + + function getWidgetUrlFromTrigger(trigger) { + if (!trigger || typeof trigger.getAttribute !== "function") { + return ""; + } + return String( + trigger.getAttribute("data-widget-url") + || trigger.getAttribute("hx-get") + || "" + ).trim(); + } + + function getRequestedWidgetIdFromTrigger(trigger) { + if (!trigger || typeof trigger.getAttribute !== "function") { + return ""; + } + const explicitId = String(trigger.getAttribute("data-gia-widget-id") || "").trim(); + if (explicitId) { + return explicitId; + } + return buildComposeWidgetIdFromUrl(getWidgetUrlFromTrigger(trigger)); + } + + function clearPendingWidgetRequest(trigger) { + if (!trigger || !trigger.dataset) { + return; + } + const widgetId = String(trigger.dataset.giaPendingWidgetId || "").trim(); + if (widgetId) { + workspaceState.pendingWidgetIds.delete(widgetId); + } + delete trigger.dataset.giaPendingWidgetId; + } + + function activateExistingWidget(widgetId) { + const id = String(widgetId || "").trim(); + if (!id || !hasWidget(id)) { + return false; + } + if (workspaceState.minimized.has(id)) { + restoreWidget(id); + } else { + setActiveWidget(id); + syncWidgetChrome(); + renderTaskbar(); + } + if (window.GIAComposePanel && typeof window.GIAComposePanel.scrollWidgetToLatest === "function") { + window.GIAComposePanel.scrollWidgetToLatest(id); + } + return true; + } + function clearSnapAssistant() { workspaceState.snapAssistantSourceId = ""; + workspaceState.snapAssistantMode = ""; const assistant = getSnapAssistant(); if (assistant) { assistant.classList.add("is-hidden"); @@ -265,23 +409,64 @@ } function normalizeSnapState() { + if ( + workspaceState.anchorWidgetId + && ( + !hasWidget(workspaceState.anchorWidgetId) + || workspaceState.minimized.has(workspaceState.anchorWidgetId) + ) + ) { + workspaceState.anchorWidgetId = ""; + } if (!hasWidget(workspaceState.snapLeftId)) { workspaceState.snapLeftId = ""; } if (!hasWidget(workspaceState.snapRightId)) { workspaceState.snapRightId = ""; } + if (!hasWidget(workspaceState.snapTopId)) { + workspaceState.snapTopId = ""; + } + if (!hasWidget(workspaceState.snapBottomId)) { + workspaceState.snapBottomId = ""; + } if (workspaceState.snapLeftId && workspaceState.snapLeftId === workspaceState.snapRightId) { workspaceState.snapRightId = ""; } + if (workspaceState.snapTopId && workspaceState.snapTopId === workspaceState.snapBottomId) { + workspaceState.snapBottomId = ""; + } + if (workspaceState.snapTopId || workspaceState.snapBottomId) { + workspaceState.snapLeftId = ""; + workspaceState.snapRightId = ""; + } + if (workspaceState.snapLeftId || workspaceState.snapRightId) { + workspaceState.snapTopId = ""; + workspaceState.snapBottomId = ""; + } if ( workspaceState.snapAssistantSourceId && workspaceState.snapAssistantSourceId !== workspaceState.snapLeftId + && workspaceState.snapAssistantSourceId !== workspaceState.snapTopId ) { workspaceState.snapAssistantSourceId = ""; + workspaceState.snapAssistantMode = ""; } } + function getSnapAssistantMeta() { + if (workspaceState.snapAssistantMode === "vertical") { + return { + title: "Snap Bottom", + message: "Choose a second window for the bottom side.", + }; + } + return { + title: "Snap Right", + message: "Choose a second window for the right side.", + }; + } + function setActiveWidget(widgetId) { const id = String(widgetId || "").trim(); if (!id) { @@ -297,9 +482,73 @@ if (widgetNode) { widgetNode.classList.add("is-gia-active"); } + syncWidgetChrome(); renderTaskbar(); } + function syncWidgetChrome() { + const anchorId = getAnchorWidgetId(syncKnownWidgetOrder()); + toArray(document.querySelectorAll(".grid-stack-item[id]")).forEach(function (node) { + const widgetId = String(node.id || "").trim(); + const spawned = widgetId && widgetId === workspaceState.lastSpawnedId; + const gridNode = node.gridstackNode || null; + const atLeftEdge = !!( + gridNode + && Number(gridNode.x || 0) === 0 + && Number(gridNode.w || GRID_COLUMNS) < GRID_COLUMNS + ); + const atRightEdge = !!( + gridNode + && Number((gridNode.x || 0) + (gridNode.w || GRID_COLUMNS)) === GRID_COLUMNS + && Number(gridNode.w || GRID_COLUMNS) < GRID_COLUMNS + ); + node.classList.toggle("is-gia-anchor", widgetId === anchorId); + node.classList.toggle("is-gia-spawned", spawned); + node.querySelectorAll('.js-gia-widget-action[data-gia-action="snap-left"]').forEach( + function (button) { + const icon = button.querySelector("i"); + const label = atLeftEdge ? "Choose window for right side" : "Snap window left"; + button.setAttribute("aria-label", label); + button.setAttribute("title", label); + button.classList.toggle("is-link", atLeftEdge); + if (icon) { + icon.className = atLeftEdge + ? "fa-solid fa-table-columns" + : "fa-solid fa-arrow-left"; + } + } + ); + node.querySelectorAll('.js-gia-widget-action[data-gia-action="snap-right"]').forEach( + function (button) { + const icon = button.querySelector("i"); + const label = atRightEdge ? "Choose window for left side" : "Snap window right"; + button.setAttribute("aria-label", label); + button.setAttribute("title", label); + button.classList.toggle("is-link", atRightEdge); + if (icon) { + icon.className = atRightEdge + ? "fa-solid fa-table-columns" + : "fa-solid fa-arrow-right"; + } + } + ); + }); + } + + function flashSpawnedWidget(widgetNode) { + if (!widgetNode || !widgetNode.id) { + return; + } + workspaceState.lastSpawnedId = widgetNode.id; + syncWidgetChrome(); + window.setTimeout(function () { + if (workspaceState.lastSpawnedId === widgetNode.id) { + workspaceState.lastSpawnedId = ""; + syncWidgetChrome(); + } + }, 1800); + } + function bindWidgetLifecycle(widgetNode) { if (!widgetNode || widgetNode.dataset.giaWidgetBound === "1") { return; @@ -326,6 +575,31 @@ setActiveWidget(widgetNode.id); } + function rememberPendingSpawn(trigger) { + const sourceWidget = trigger && trigger.closest + ? trigger.closest(".grid-stack-item[id]") + : null; + const sourceId = String(sourceWidget && sourceWidget.id ? sourceWidget.id : "").trim(); + workspaceState.pendingSpawnSourceId = sourceId; + workspaceState.pendingSpawnTs = Date.now(); + if (sourceId) { + workspaceState.anchorWidgetId = sourceId; + syncWidgetChrome(); + renderTaskbar(); + } + } + + function consumePendingSpawnContext() { + const sourceId = String(workspaceState.pendingSpawnSourceId || "").trim(); + const spawnTs = Number(workspaceState.pendingSpawnTs || 0); + workspaceState.pendingSpawnSourceId = ""; + workspaceState.pendingSpawnTs = 0; + if (!sourceId || (spawnTs > 0 && Date.now() - spawnTs > 15000)) { + return { sourceId: "" }; + } + return { sourceId: sourceId }; + } + function renderTaskbar() { const taskbar = getTaskbar(); const itemsNode = getTaskbarItems(); @@ -373,27 +647,31 @@ item.appendChild(link); itemsNode.appendChild(item); }); + syncWidgetChrome(); taskbar.classList.remove("is-hidden"); } - function buildSnapAssistantOption(widgetId, widgetNode) { + function buildSnapAssistantOption(widgetMeta) { const button = document.createElement("button"); button.type = "button"; button.className = "button is-light js-gia-snap-target"; - button.dataset.giaWidgetId = widgetId; + button.dataset.giaWidgetId = widgetMeta.id; + if (widgetMeta.minimized) { + button.dataset.giaWidgetState = "minimized"; + } const leftGroup = document.createElement("span"); leftGroup.className = "is-inline-flex is-align-items-center"; const iconWrap = document.createElement("span"); iconWrap.className = "icon is-small mr-2"; const icon = document.createElement("i"); - icon.className = getWidgetIconClass(widgetNode); + icon.className = widgetMeta.iconClass; iconWrap.appendChild(icon); const label = document.createElement("span"); - label.textContent = getWidgetTitle(widgetNode); + label.textContent = widgetMeta.title; leftGroup.appendChild(iconWrap); leftGroup.appendChild(label); const actionLabel = document.createElement("span"); - actionLabel.textContent = "Use"; + actionLabel.textContent = widgetMeta.minimized ? "Restore" : "Use"; button.appendChild(leftGroup); button.appendChild(actionLabel); return button; @@ -411,17 +689,26 @@ clearSnapAssistant(); return; } - const candidates = getVisibleWidgetNodes().filter(function (node) { - return node.id !== sourceId; + const candidates = syncKnownWidgetOrder().filter(function (widgetId) { + return widgetId !== sourceId && hasWidget(widgetId); }); if (!candidates.length) { clearSnapAssistant(); return; } options.innerHTML = ""; - candidates.forEach(function (node) { - options.appendChild(buildSnapAssistantOption(node.id, node)); + candidates.forEach(function (widgetId) { + options.appendChild(buildSnapAssistantOption(getWidgetMeta(widgetId))); }); + const meta = getSnapAssistantMeta(); + const headingText = assistant.querySelector(".gia-snap-assistant-title"); + if (headingText) { + headingText.textContent = meta.title; + } + const bodyText = assistant.querySelector(".gia-snap-assistant-message"); + if (bodyText) { + bodyText.textContent = meta.message; + } assistant.classList.remove("is-hidden"); } @@ -454,6 +741,9 @@ const cellHeight = Math.max(32, Math.floor(height / GRID_ROWS)); gridElement.style.height = height + "px"; window.grid.cellHeight(cellHeight); + if (typeof window.grid.setAnimation === "function") { + window.grid.setAnimation(!window.matchMedia(MOBILE_MEDIA_QUERY).matches); + } if (typeof window.grid.column === "function") { window.grid.column(GRID_COLUMNS); } @@ -549,35 +839,177 @@ }); } - function buildSnappedLayout(profile) { - if (profile.isMobile) { + function buildWorkspaceLayoutItems(widgetNodes, profile) { + const snappedLayout = buildSnappedLayout(profile); + const anchoredLayout = snappedLayout ? null : buildAnchoredLayout(widgetNodes, profile); + return snappedLayout || anchoredLayout || buildDefaultLayout(widgetNodes, profile); + } + + function buildIncomingLayout(widgetNode, spawnSourceId) { + if (!widgetNode) { return null; } - const leftNode = getWidgetNode(workspaceState.snapLeftId); - const rightNode = getWidgetNode(workspaceState.snapRightId); - if (!leftNode || !rightNode) { + const sourceId = String(spawnSourceId || "").trim(); + const profile = getLayoutProfile(); + const existingNodes = getVisibleWidgetNodes().filter(function (node) { + return node.id !== widgetNode.id; + }); + let widgetNodes = existingNodes.slice(); + if (sourceId) { + const sourceIndex = widgetNodes.findIndex(function (node) { + return node.id === sourceId; + }); + if (sourceIndex >= 0) { + widgetNodes.splice(sourceIndex + 1, 0, widgetNode); + } else { + widgetNodes.push(widgetNode); + } + } else { + widgetNodes.push(widgetNode); + } + const previousSpawnedId = workspaceState.lastSpawnedId; + if (sourceId && sourceId !== widgetNode.id) { + workspaceState.lastSpawnedId = widgetNode.id; + } + const layoutItems = buildWorkspaceLayoutItems(widgetNodes, profile); + workspaceState.lastSpawnedId = previousSpawnedId; + return layoutItems.find(function (item) { + return item && item.node === widgetNode; + }) || null; + } + + function buildAnchoredLayout(widgetNodes, profile) { + if (!widgetNodes.length || widgetNodes.length < 2) { return null; } - if (leftNode.parentElement !== getGridElement()) { + const anchorId = getAnchorWidgetId( + widgetNodes.map(function (node) { + return node.id; + }) + ); + if (!anchorId) { return null; } - if (rightNode.parentElement !== getGridElement()) { + const anchorNode = widgetNodes.find(function (node) { + return node.id === anchorId; + }); + if (!anchorNode) { return null; } - return [ + let secondaryNodes = widgetNodes.filter(function (node) { + return node.id !== anchorId; + }); + const spawnedIndex = secondaryNodes.findIndex(function (node) { + return node.id === workspaceState.lastSpawnedId; + }); + if (spawnedIndex > 0) { + secondaryNodes = [secondaryNodes[spawnedIndex]].concat( + secondaryNodes.filter(function (_node, index) { + return index !== spawnedIndex; + }) + ); + } + if (!secondaryNodes.length) { + return null; + } + if (profile.isMobile || !profile.isDesktop) { + return [ + { + node: anchorNode, + x: 0, + y: 0, + w: GRID_COLUMNS, + h: GRID_ROWS / 2, + }, + { + node: secondaryNodes[0], + x: 0, + y: GRID_ROWS / 2, + w: GRID_COLUMNS, + h: GRID_ROWS / 2, + }, + ]; + } + const stackedNodes = secondaryNodes.slice(0, 3); + const layoutItems = [ { - node: leftNode, + node: anchorNode, x: 0, y: 0, w: GRID_COLUMNS / 2, h: GRID_ROWS, }, - { - node: rightNode, + ]; + const baseHeight = Math.floor(GRID_ROWS / stackedNodes.length); + let offsetY = 0; + stackedNodes.forEach(function (node, index) { + const height = index === stackedNodes.length - 1 + ? GRID_ROWS - offsetY + : baseHeight; + layoutItems.push({ + node: node, x: GRID_COLUMNS / 2, - y: 0, + y: offsetY, w: GRID_COLUMNS / 2, - h: GRID_ROWS, + h: height, + }); + offsetY += height; + }); + return layoutItems; + } + + function buildSnappedLayout(profile) { + const gridElement = getGridElement(); + const leftNode = getWidgetNode(workspaceState.snapLeftId); + const rightNode = getWidgetNode(workspaceState.snapRightId); + if ( + leftNode + && rightNode + && leftNode.parentElement === gridElement + && rightNode.parentElement === gridElement + && !profile.isMobile + ) { + return [ + { + node: leftNode, + x: 0, + y: 0, + w: GRID_COLUMNS / 2, + h: GRID_ROWS, + }, + { + node: rightNode, + x: GRID_COLUMNS / 2, + y: 0, + w: GRID_COLUMNS / 2, + h: GRID_ROWS, + }, + ]; + } + const topNode = getWidgetNode(workspaceState.snapTopId); + const bottomNode = getWidgetNode(workspaceState.snapBottomId); + if ( + !topNode + || !bottomNode + || topNode.parentElement !== gridElement + || bottomNode.parentElement !== gridElement + ) { + return null; + } + return [ + { + node: topNode, + x: 0, + y: 0, + w: GRID_COLUMNS, + h: GRID_ROWS / 2, + }, + { + node: bottomNode, + x: 0, + y: GRID_ROWS / 2, + w: GRID_COLUMNS, + h: GRID_ROWS / 2, }, ]; } @@ -615,7 +1047,8 @@ workspaceState.activeWidgetId = ""; return; } - workspaceState.activeWidgetId = visibleIds[visibleIds.length - 1]; + const anchorId = getAnchorWidgetId(visibleIds); + workspaceState.activeWidgetId = anchorId || visibleIds[visibleIds.length - 1]; } function minimizeWidget(widgetId, options) { @@ -632,6 +1065,9 @@ if (workspaceState.activeWidgetId === widgetId) { chooseFallbackActiveWidget(); } + if (workspaceState.lastSpawnedId === widgetId) { + workspaceState.lastSpawnedId = ""; + } if (workspaceState.snapLeftId === widgetId) { workspaceState.snapLeftId = ""; workspaceState.snapRightId = ""; @@ -641,6 +1077,18 @@ workspaceState.snapRightId = ""; clearSnapAssistant(); } + if (workspaceState.snapTopId === widgetId) { + workspaceState.snapTopId = ""; + workspaceState.snapBottomId = ""; + clearSnapAssistant(); + } + if (workspaceState.snapBottomId === widgetId) { + workspaceState.snapBottomId = ""; + clearSnapAssistant(); + } + if (workspaceState.anchorWidgetId === widgetId) { + workspaceState.anchorWidgetId = ""; + } detachWidgetFromGrid(widgetNode); const stash = getWorkspaceStash(); if (stash) { @@ -676,12 +1124,24 @@ if (workspaceState.activeWidgetId === widgetId) { workspaceState.activeWidgetId = ""; } + if (workspaceState.anchorWidgetId === widgetId) { + workspaceState.anchorWidgetId = ""; + } + if (workspaceState.lastSpawnedId === widgetId) { + workspaceState.lastSpawnedId = ""; + } if (workspaceState.snapLeftId === widgetId) { workspaceState.snapLeftId = ""; } if (workspaceState.snapRightId === widgetId) { workspaceState.snapRightId = ""; } + if (workspaceState.snapTopId === widgetId) { + workspaceState.snapTopId = ""; + } + if (workspaceState.snapBottomId === widgetId) { + workspaceState.snapBottomId = ""; + } if (workspaceState.snapAssistantSourceId === widgetId) { clearSnapAssistant(); } @@ -710,20 +1170,25 @@ function enforceVisibleLimit(profile) { normalizeSnapState(); const snappedLayout = buildSnappedLayout(profile); + const anchorId = getAnchorWidgetId(syncKnownWidgetOrder()); const visibleIds = getVisibleWidgetIds(); - const protectedIds = new Set( + const hardProtectedIds = new Set([anchorId].filter(Boolean)); + const softProtectedIds = new Set( [ workspaceState.activeWidgetId, workspaceState.snapLeftId, workspaceState.snapRightId, + workspaceState.snapTopId, + workspaceState.snapBottomId, ].filter(Boolean) ); - const maxVisible = snappedLayout ? 2 : profile.maxVisible; + const anchoredMobileSplit = !snappedLayout && !!anchorId && profile.isMobile; + const maxVisible = snappedLayout ? 2 : anchoredMobileSplit ? 2 : profile.maxVisible; if (visibleIds.length <= maxVisible) { return; } const removableIds = visibleIds.filter(function (widgetId) { - return !protectedIds.has(widgetId); + return !hardProtectedIds.has(widgetId) && !softProtectedIds.has(widgetId); }); while (getVisibleWidgetIds().length > maxVisible && removableIds.length) { minimizeWidget(removableIds.shift(), { skipLayout: true }); @@ -731,7 +1196,9 @@ const remainingIds = getVisibleWidgetIds(); while (remainingIds.length > maxVisible) { const candidate = remainingIds.find(function (widgetId) { - return widgetId !== workspaceState.activeWidgetId; + return !hardProtectedIds.has(widgetId) && !softProtectedIds.has(widgetId); + }) || remainingIds.find(function (widgetId) { + return !hardProtectedIds.has(widgetId); }); if (!candidate) { break; @@ -746,6 +1213,34 @@ return; } syncGridMetrics(); + layoutItems.forEach(function (item) { + writeGridPositionAttrs(item.node, item); + }); + const canLoadAtomically = typeof window.grid.load === "function" + && layoutItems.every(function (item) { + return !!( + item + && item.node + && item.node.id + && item.node.gridstackNode + && String(item.node.gridstackNode.id || "") === String(item.node.id || "") + ); + }); + if (canLoadAtomically) { + window.grid.load( + layoutItems.map(function (item) { + return { + id: item.node.id, + x: item.x, + y: item.y, + w: item.w, + h: item.h, + }; + }), + false + ); + return; + } if (typeof window.grid.batchUpdate === "function") { window.grid.batchUpdate(true); } @@ -770,8 +1265,8 @@ const profile = getLayoutProfile(); enforceVisibleLimit(profile); renderSnapAssistant(); - const snappedLayout = buildSnappedLayout(profile); - const layoutItems = snappedLayout || buildDefaultLayout(getVisibleWidgetNodes(), profile); + const visibleNodes = getVisibleWidgetNodes(); + const layoutItems = buildWorkspaceLayoutItems(visibleNodes, profile); applyLayout(layoutItems); if (!workspaceState.activeWidgetId && layoutItems.length) { setActiveWidget(layoutItems[layoutItems.length - 1].node.id); @@ -792,6 +1287,10 @@ workspaceState.snapLeftId = ""; workspaceState.snapRightId = ""; } + if (workspaceState.snapTopId === widgetId || workspaceState.snapBottomId === widgetId) { + workspaceState.snapTopId = ""; + workspaceState.snapBottomId = ""; + } if (workspaceState.snapAssistantSourceId === widgetId) { clearSnapAssistant(); } @@ -799,17 +1298,47 @@ layoutWorkspace(); } - function chooseSnapCandidate(excludeId) { - const candidates = getVisibleWidgetNodes().filter(function (node) { - return node.id !== excludeId; + function getSnapCandidateIds(excludeId, options) { + const config = options || {}; + const candidates = []; + const pushCandidate = function (widgetId) { + const id = String(widgetId || "").trim(); + if (!id || id === excludeId || candidates.includes(id) || !hasWidget(id)) { + return; + } + candidates.push(id); + }; + const visibleIds = getVisibleWidgetIds().filter(function (widgetId) { + return widgetId !== excludeId; }); - if (!candidates.length) { + pushCandidate(getAnchorWidgetId(visibleIds)); + pushCandidate(workspaceState.activeWidgetId); + visibleIds.forEach(pushCandidate); + if (config.includeMinimized) { + getLockedWidgetIds().forEach(pushCandidate); + syncKnownWidgetOrder().forEach(function (widgetId) { + if (workspaceState.minimized.has(widgetId)) { + pushCandidate(widgetId); + } + }); + } + return candidates; + } + + function chooseSnapCandidate(excludeId, options) { + const candidates = getSnapCandidateIds(excludeId, options); + return candidates.length ? candidates[0] : ""; + } + + function ensureWidgetVisible(widgetId) { + const id = String(widgetId || "").trim(); + if (!id) { return ""; } - const active = candidates.find(function (node) { - return node.id === workspaceState.activeWidgetId; - }); - return active ? active.id : candidates[candidates.length - 1].id; + if (workspaceState.minimized.has(id)) { + restoreWidget(id, { skipLayout: true }); + } + return id; } function snapWidgetLeft(widgetId) { @@ -817,9 +1346,18 @@ if (!id) { return; } + const candidates = getSnapCandidateIds(id, { includeMinimized: true }); + workspaceState.snapTopId = ""; + workspaceState.snapBottomId = ""; workspaceState.snapLeftId = id; - workspaceState.snapRightId = ""; - workspaceState.snapAssistantSourceId = id; + if (candidates.length === 1) { + workspaceState.snapRightId = ensureWidgetVisible(candidates[0]); + clearSnapAssistant(); + } else { + workspaceState.snapRightId = ""; + workspaceState.snapAssistantSourceId = id; + workspaceState.snapAssistantMode = "horizontal"; + } setActiveWidget(id); layoutWorkspace(); } @@ -830,8 +1368,12 @@ return; } if (!workspaceState.snapLeftId || workspaceState.snapLeftId === id) { - workspaceState.snapLeftId = chooseSnapCandidate(id); + workspaceState.snapLeftId = ensureWidgetVisible( + chooseSnapCandidate(id, { includeMinimized: true }) + ); } + workspaceState.snapTopId = ""; + workspaceState.snapBottomId = ""; workspaceState.snapRightId = id; clearSnapAssistant(); setActiveWidget(id); @@ -843,12 +1385,63 @@ if (!id) { return; } + ensureWidgetVisible(id); + workspaceState.snapTopId = ""; + workspaceState.snapBottomId = ""; workspaceState.snapRightId = id; clearSnapAssistant(); setActiveWidget(id); layoutWorkspace(); } + function snapWidgetTop(widgetId) { + const id = String(widgetId || "").trim(); + if (!id) { + return; + } + const partnerId = ensureWidgetVisible( + chooseSnapCandidate(id, { includeMinimized: true }) + ); + workspaceState.snapLeftId = ""; + workspaceState.snapRightId = ""; + workspaceState.snapTopId = id; + workspaceState.snapBottomId = partnerId; + clearSnapAssistant(); + setActiveWidget(id); + layoutWorkspace(); + } + + function snapWidgetBottom(widgetId) { + const id = String(widgetId || "").trim(); + if (!id) { + return; + } + const partnerId = ensureWidgetVisible( + chooseSnapCandidate(id, { includeMinimized: true }) + ); + workspaceState.snapLeftId = ""; + workspaceState.snapRightId = ""; + workspaceState.snapTopId = partnerId; + workspaceState.snapBottomId = id; + clearSnapAssistant(); + setActiveWidget(id); + layoutWorkspace(); + } + + function chooseSnapBottom(widgetId) { + const id = String(widgetId || "").trim(); + if (!id) { + return; + } + ensureWidgetVisible(id); + workspaceState.snapLeftId = ""; + workspaceState.snapRightId = ""; + workspaceState.snapBottomId = id; + clearSnapAssistant(); + setActiveWidget(id); + layoutWorkspace(); + } + function replaceExistingWidget(widgetNode) { if (!widgetNode || !widgetNode.id) { return -1; @@ -876,6 +1469,7 @@ return Promise.resolve(); } container.dataset.giaWidgetProcessed = "1"; + const spawnContext = consumePendingSpawnContext(); const widgetNode = container.firstElementChild ? container.firstElementChild.cloneNode(true) : null; @@ -889,6 +1483,10 @@ if (!window.grid || !getGridElement()) { return Promise.resolve(); } + const initialLayout = buildIncomingLayout(widgetNode, spawnContext.sourceId); + if (initialLayout) { + writeGridPositionAttrs(widgetNode, initialLayout); + } getGridElement().appendChild(widgetNode); if (typeof window.grid.makeWidget === "function") { window.grid.makeWidget(widgetNode); @@ -900,12 +1498,29 @@ } window.giaEnableWidgetSpawnButtons(widgetNode); const liveWidget = widgetNode.id ? document.getElementById(widgetNode.id) : widgetNode; - registerWidget(liveWidget, previousIndex); + if (liveWidget && liveWidget.id) { + workspaceState.pendingWidgetIds.delete(liveWidget.id); + } + let insertIndex = previousIndex; + if (spawnContext.sourceId && spawnContext.sourceId !== liveWidget.id) { + const sourceIndex = workspaceState.order.indexOf(spawnContext.sourceId); + if (sourceIndex >= 0) { + insertIndex = sourceIndex + 1; + } + workspaceState.anchorWidgetId = spawnContext.sourceId; + liveWidget.dataset.giaSpawnSourceId = spawnContext.sourceId; + } else { + delete liveWidget.dataset.giaSpawnSourceId; + } + registerWidget(liveWidget, insertIndex); return ensureContainerAssets(container).then(function () { scripts.forEach(executeWidgetScript); if (window.GIAComposePanel && typeof window.GIAComposePanel.initAll === "function") { window.GIAComposePanel.initAll(liveWidget); } + if (spawnContext.sourceId && spawnContext.sourceId !== liveWidget.id) { + flashSpawnedWidget(liveWidget); + } layoutWorkspace(); }); } @@ -923,10 +1538,22 @@ scope.querySelectorAll(WIDGET_SHELL_SELECTOR).forEach(function (node) { containers.push(node); }); - Promise.all(containers.map(processWidgetShell)).finally(function () { - layoutWorkspace(); - window.giaEnableWidgetSpawnButtons(document); - }); + widgetShellProcessingChain = widgetShellProcessingChain + .catch(function () { + return null; + }) + .then(function () { + return containers.reduce(function (promise, containerNode) { + return promise.then(function () { + return processWidgetShell(containerNode); + }); + }, Promise.resolve()); + }) + .finally(function () { + layoutWorkspace(); + window.giaEnableWidgetSpawnButtons(document); + }); + return widgetShellProcessingChain; } function enableFloatingWindowInteractions(windowEl) { @@ -1041,8 +1668,16 @@ snapWidgetLeft(widgetId); return; } + if (action === "snap-top") { + snapWidgetTop(widgetId); + return; + } if (action === "snap-right") { snapWidgetRight(widgetId); + return; + } + if (action === "snap-bottom") { + snapWidgetBottom(widgetId); } } @@ -1130,8 +1765,10 @@ ); document.addEventListener("click", function (event) { - const spawnTrigger = event.target.closest(WIDGET_SPAWN_SELECTOR); + const spawnTrigger = event.target.closest(WIDGET_SPAWN_SELECTOR) + || event.target.closest(WIDGET_LOAD_TARGET_SELECTOR); if (spawnTrigger) { + rememberPendingSpawn(spawnTrigger); window.giaPrepareWidgetTarget(); } @@ -1161,7 +1798,11 @@ const snapTarget = event.target.closest(".js-gia-snap-target"); if (snapTarget) { event.preventDefault(); - chooseSnapRight(String(snapTarget.dataset.giaWidgetId || "")); + if (workspaceState.snapAssistantMode === "vertical") { + chooseSnapBottom(String(snapTarget.dataset.giaWidgetId || "")); + } else { + chooseSnapRight(String(snapTarget.dataset.giaWidgetId || "")); + } return; } @@ -1171,6 +1812,42 @@ } }); + document.body.addEventListener("htmx:beforeRequest", function (event) { + const trigger = event && event.detail ? event.detail.elt : null; + if (!trigger || typeof trigger.getAttribute !== "function") { + return; + } + const target = event.detail ? event.detail.target : null; + const targetId = String((target && target.id) || trigger.getAttribute("hx-target") || "") + .replace(/^#/, "") + .trim(); + if (targetId !== "widgets-here") { + return; + } + const widgetId = getRequestedWidgetIdFromTrigger(trigger); + if (!widgetId) { + return; + } + if (workspaceState.pendingWidgetIds.has(widgetId) || hasWidget(widgetId)) { + event.preventDefault(); + activateExistingWidget(widgetId); + return; + } + workspaceState.pendingWidgetIds.add(widgetId); + trigger.dataset.giaPendingWidgetId = widgetId; + }); + + document.body.addEventListener("htmx:afterRequest", function (event) { + const trigger = event && event.detail ? event.detail.elt : null; + if (!trigger || !trigger.dataset || !trigger.dataset.giaPendingWidgetId) { + return; + } + if (event.detail && event.detail.successful) { + return; + } + clearPendingWidgetRequest(trigger); + }); + document.body.addEventListener("htmx:afterSwap", function (event) { const target = (event && event.target) || document; window.giaEnableWidgetSpawnButtons(target); diff --git a/core/templates/base.html b/core/templates/base.html index 4280c99..fbc4580 100644 --- a/core/templates/base.html +++ b/core/templates/base.html @@ -43,7 +43,7 @@ - + {% block extra_head_assets %}{% endblock %} @@ -163,6 +163,49 @@ }); }); + var closeGiaDropdowns = function (exceptNode) { + document.querySelectorAll(".dropdown[data-gia-dropdown].is-active").forEach(function (node) { + if (exceptNode && node === exceptNode) { + return; + } + node.classList.remove("is-active"); + var trigger = node.querySelector(".js-gia-dropdown-toggle"); + if (trigger) { + trigger.setAttribute("aria-expanded", "false"); + } + }); + }; + + document.addEventListener("click", function (event) { + var toggle = event.target.closest(".js-gia-dropdown-toggle"); + if (toggle) { + event.preventDefault(); + event.stopPropagation(); + var dropdown = toggle.closest(".dropdown[data-gia-dropdown]"); + if (!dropdown) { + return; + } + var nextState = !dropdown.classList.contains("is-active"); + closeGiaDropdowns(nextState ? dropdown : null); + dropdown.classList.toggle("is-active", nextState); + toggle.setAttribute("aria-expanded", nextState ? "true" : "false"); + return; + } + if (!event.target.closest(".dropdown[data-gia-dropdown]")) { + closeGiaDropdowns(null); + return; + } + if (event.target.closest(".dropdown[data-gia-dropdown] .dropdown-item") && !event.target.closest("summary")) { + closeGiaDropdowns(null); + } + }); + + document.addEventListener("keydown", function (event) { + if (event.key === "Escape") { + closeGiaDropdowns(null); + } + }); + document.body.addEventListener("htmx:afterRequest", function (event) { const detail = (event && event.detail) || null; const source = detail && detail.elt ? detail.elt : null; diff --git a/core/templates/index.html b/core/templates/index.html index 8c76409..156f719 100644 --- a/core/templates/index.html +++ b/core/templates/index.html @@ -24,7 +24,7 @@ aria-label="Snap assistant">

- Snap Right + Snap Right + + + {% endif %} + + + diff --git a/core/templates/partials/behavioral-graph-launcher.html b/core/templates/partials/behavioral-graph-launcher.html new file mode 100644 index 0000000..d708bc3 --- /dev/null +++ b/core/templates/partials/behavioral-graph-launcher.html @@ -0,0 +1,71 @@ +

diff --git a/core/templates/partials/behavioral-range-tabs.html b/core/templates/partials/behavioral-range-tabs.html new file mode 100644 index 0000000..1075aa0 --- /dev/null +++ b/core/templates/partials/behavioral-range-tabs.html @@ -0,0 +1,56 @@ +
+ +
diff --git a/core/templates/partials/behavioral-summary-card.html b/core/templates/partials/behavioral-summary-card.html new file mode 100644 index 0000000..c288770 --- /dev/null +++ b/core/templates/partials/behavioral-summary-card.html @@ -0,0 +1,18 @@ +
+
+

+ {{ card.state_label }} · {{ card.group|upper }} +

+
+

+ + {{ card.title }} +

+ {{ card.current_value_label }} +
+

{{ card.calculation }}

+ {% if card.delta_label %} +

Latest shift: {{ card.delta_label }}

+ {% endif %} +
+
diff --git a/core/templates/partials/bulma-send-composer.html b/core/templates/partials/bulma-send-composer.html index 15291af..b65b376 100644 --- a/core/templates/partials/bulma-send-composer.html +++ b/core/templates/partials/bulma-send-composer.html @@ -1,5 +1,5 @@ -
-
+
+