@k2b/cloud 0.38.0 → 0.40.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -0
- package/package.json +12 -5
- package/scripts/build-canvas-workers.ts +109 -29
- package/scripts/build.ts +3 -5
- package/src/_internal/app-favicon.ts +4 -2
- package/src/_internal/app-path.ts +25 -0
- package/src/_internal/app-presentation.ts +4 -0
- package/src/_internal/canvas-worker.ts +5 -3
- package/src/_internal/capabilities.ts +72 -48
- package/src/_internal/capability-reviews.ts +81 -0
- package/src/_internal/capability-schemas.ts +29 -0
- package/src/_internal/capability-sentences.ts +216 -1
- package/src/_internal/capability-streams.ts +7 -0
- package/src/_internal/define-app.ts +63 -16
- package/src/_internal/font-preloads.ts +2 -1
- package/src/_internal/panel-cookie.ts +39 -0
- package/src/_internal/registry-validation.ts +21 -26
- package/src/_internal/runtime-context.ts +1 -0
- package/src/_internal/runtime-watcher.ts +6 -0
- package/src/_internal/static-assets.ts +52 -3
- package/src/_internal/status-preserving-ssr.ts +2 -0
- package/src/access/PermissionEditor.browser-harness.tsx +57 -38
- package/src/access/PermissionEditor.tsx +32 -7
- package/src/access/ResourceApiKeys.tsx +5 -1
- package/src/account/EntitySearch.tsx +1 -1
- package/src/ai/action-review.ts +101 -0
- package/src/ai/approval-routes.ts +5 -2
- package/src/ai/approvals.ts +139 -28
- package/src/ai/background-review.ts +72 -0
- package/src/ai/browser.ts +10 -0
- package/src/ai/capabilities.ts +29 -11
- package/src/ai/capability-execution.ts +76 -43
- package/src/ai/capability-result.ts +95 -0
- package/src/ai/chat/action-card.ts +398 -0
- package/src/ai/chat/action-messages.ts +129 -0
- package/src/ai/chat/action-question.browser-harness.tsx +245 -0
- package/src/ai/chat/action-question.fixtures.ts +733 -0
- package/src/ai/chat/action-question.tsx +669 -0
- package/src/ai/chat/action-sentences.ts +1 -44
- package/src/ai/chat/blocks.tsx +19 -25
- package/src/ai/chat/message-actions.tsx +6 -0
- package/src/ai/chat/tool-action.tsx +481 -0
- package/src/ai/chat/turn-view.tsx +45 -163
- package/src/ai/chat/view-block.tsx +22 -7
- package/src/ai/chat-task-contracts.ts +31 -3
- package/src/ai/chat-task-reasons-migrate.ts +75 -0
- package/src/ai/chat-task-reasons.ts +92 -0
- package/src/ai/chat-tasks.ts +438 -78
- package/src/ai/client/controller.ts +59 -0
- package/src/ai/default-tools.ts +9 -1
- package/src/ai/executor.ts +190 -39
- package/src/ai/index.ts +14 -0
- package/src/ai/migrate.ts +39 -4
- package/src/ai/projects-routes.ts +3 -0
- package/src/ai/protocol.ts +14 -2
- package/src/ai/recent-resources.ts +240 -0
- package/src/ai/recent-sent-refs.ts +38 -0
- package/src/ai/request-context.ts +7 -1
- package/src/ai/routes.ts +172 -2
- package/src/ai/runtime.ts +13 -27
- package/src/ai/skills-routes.ts +3 -0
- package/src/ai/skills.ts +10 -1
- package/src/ai/store.ts +232 -27
- package/src/ai/timeline.ts +9 -1
- package/src/ai/tool-audit.ts +8 -0
- package/src/ai/tool-wording.ts +67 -0
- package/src/ai/turn-abort.ts +33 -0
- package/src/ai/types.ts +40 -4
- package/src/api/accounts-entities.ts +2 -1
- package/src/api/admin-ai-projects.ts +2 -0
- package/src/api/admin-ai-skills.ts +2 -0
- package/src/api/admin-ai-usage.ts +2 -0
- package/src/api/admin-core-settings.ts +190 -139
- package/src/api/admin-geoip.ts +80 -0
- package/src/api/admin-identity.ts +3 -0
- package/src/api/admin-lifecycle.ts +3 -0
- package/src/api/admin-outgoing-mail.ts +6 -2
- package/src/api/app-approval.ts +54 -6
- package/src/api/approvals-loader.ts +23 -1
- package/src/api/approvals.ts +3 -1
- package/src/api/auth.ts +25 -6
- package/src/api/capabilities.ts +44 -11
- package/src/api/capability-streams.ts +12 -0
- package/src/api/help.ts +4 -28
- package/src/api/index.ts +7 -1
- package/src/api/lasting-access-messages.ts +17 -0
- package/src/api/mcp.ts +3 -0
- package/src/api/me-panel-consent.ts +71 -0
- package/src/api/me.ts +80 -33
- package/src/api/search.ts +3 -1
- package/src/api/sync-ops.ts +2 -0
- package/src/api/widgets.ts +3 -0
- package/src/approvals/Approvals.tsx +180 -238
- package/src/approvals/messages.ts +12 -24
- package/src/browser/CloudResourceSearch.browser-harness.tsx +10 -2
- package/src/browser/CloudResourceSearch.tsx +10 -6
- package/src/browser/app-approval.ts +60 -6
- package/src/browser/commands.ts +22 -6
- package/src/browser/composer-catalog.ts +145 -0
- package/src/browser/composer-insert-messages.ts +31 -0
- package/src/browser/composer-insert.ts +160 -0
- package/src/browser/composer-open-messages.ts +17 -0
- package/src/browser/composer-open.ts +110 -0
- package/src/browser/discoverable-command.ts +6 -0
- package/src/browser/hints.ts +6 -0
- package/src/browser/live-connection.ts +26 -2
- package/src/browser/navigation-apps.ts +7 -0
- package/src/browser/panel-context-bridge.ts +30 -0
- package/src/browser/panel-context-receiver.ts +53 -0
- package/src/browser/panel-menu.tsx +56 -0
- package/src/browser/panel-messages.ts +35 -0
- package/src/browser/panel-shortcuts.ts +31 -0
- package/src/browser/panel-slot.ts +756 -0
- package/src/browser/panel.ts +65 -0
- package/src/browser/resource-picker.tsx +12 -1
- package/src/browser/search-commands.ts +6 -3
- package/src/capabilities/client.ts +8 -2
- package/src/capabilities/reviews.ts +205 -0
- package/src/capabilities/server.ts +10 -3
- package/src/capabilities/testing.ts +47 -0
- package/src/capabilities/types.ts +2 -2
- package/src/cli/account.ts +13 -3
- package/src/cli/admin/geoip.ts +115 -0
- package/src/cli/admin/index.ts +3 -0
- package/src/contracts/app-approval.ts +159 -12
- package/src/contracts/app.ts +14 -0
- package/src/contracts/approvals.ts +2 -2
- package/src/contracts/capabilities.ts +410 -18
- package/src/contracts/geoip.ts +42 -0
- package/src/contracts/index.ts +3 -0
- package/src/contracts/panel-context-plain.ts +99 -0
- package/src/contracts/panel-context.ts +93 -0
- package/src/contracts/registry.ts +3 -1
- package/src/contracts/seen-hints.ts +35 -0
- package/src/contracts/shared.ts +2 -0
- package/src/server/client-ip.ts +5 -0
- package/src/server/help-payload.ts +38 -0
- package/src/server/middleware/auth.ts +11 -1
- package/src/server/middleware/middleware.ts +9 -0
- package/src/server/middleware/no-transform.ts +19 -0
- package/src/server/middleware/rate-limit.ts +4 -6
- package/src/server/request-context/address.ts +45 -0
- package/src/server/request-context/geoip.ts +380 -0
- package/src/server/request-context/index.ts +33 -0
- package/src/server/request-context/user-agent.ts +95 -0
- package/src/server/services/access.ts +5 -1
- package/src/services/accounts/posix.ts +2 -1
- package/src/services/app-approval.ts +533 -90
- package/src/services/approvals/device.ts +9 -5
- package/src/services/approvals/index.ts +6 -5
- package/src/services/approvals/list.ts +26 -5
- package/src/services/auth-flows/notification-sender.ts +3 -0
- package/src/services/geoip/index.ts +164 -0
- package/src/services/geoip/store.ts +30 -0
- package/src/services/geoip/upload.ts +150 -0
- package/src/services/identity/routes.ts +5 -1
- package/src/services/index.ts +32 -3
- package/src/services/known-browsers.ts +312 -0
- package/src/services/login-address-store.ts +51 -0
- package/src/services/login-context.ts +118 -0
- package/src/services/mandates/index.ts +3 -0
- package/src/services/mandates/policy.ts +15 -3
- package/src/services/notifications/browser.ts +16 -5
- package/src/services/notifications/email-frame.ts +1 -1
- package/src/services/oauth-tokens.ts +16 -2
- package/src/services/panel-context-consent.ts +58 -0
- package/src/services/pdf/image-protocol.ts +135 -0
- package/src/services/pdf/image-variants.ts +124 -0
- package/src/services/pdf/image-worker.ts +31 -0
- package/src/services/pdf/image.ts +109 -0
- package/src/services/pdf/index.ts +9 -0
- package/src/services/service-account-credentials.ts +21 -0
- package/src/services/session/index.ts +97 -16
- package/src/services/session/recent.ts +13 -0
- package/src/services/settings/api.ts +11 -6
- package/src/services/settings/app.ts +5 -4
- package/src/services/settings/core-settings.ts +4 -3
- package/src/services/settings/index.ts +19 -17
- package/src/services/settings/namespace.ts +2 -1
- package/src/services/settings/store.ts +83 -11
- package/src/services/webauthn.ts +12 -0
- package/src/shared/ai-platform-prompt.ts +1 -1
- package/src/shared/app-presentation.ts +1 -0
- package/src/shared/email-html.ts +2 -0
- package/src/shared/help.ts +3 -0
- package/src/shared/redirect.ts +12 -0
- package/src/shared-text/cursor.ts +22 -0
- package/src/shared-text/documents.ts +142 -0
- package/src/shared-text/events.ts +43 -0
- package/src/shared-text/index.ts +12 -0
- package/src/shared-text/log.ts +48 -0
- package/src/shared-text/text-diff.ts +23 -0
- package/src/shared-text/types.ts +41 -0
- package/src/ssr/AdminLayout.tsx +2 -0
- package/src/ssr/AppLaunchpadPanel.tsx +2 -2
- package/src/ssr/CloudShell.island.tsx +95 -10
- package/src/ssr/Layout.tsx +66 -10
- package/src/ssr/LayoutHeader.tsx +15 -9
- package/src/ssr/LayoutHelp.browser-harness.tsx +2 -0
- package/src/ssr/LayoutHelp.tsx +435 -274
- package/src/ssr/LayoutHelpPanel.browser-harness.tsx +136 -0
- package/src/ssr/LayoutHelpTrigger.tsx +27 -2
- package/src/ssr/LayoutRail.tsx +2 -2
- package/src/ssr/LayoutTabBar.tsx +29 -0
- package/src/ssr/MobileNavigation.tsx +2 -2
- package/src/ssr/PanelLayout.tsx +35 -0
- package/src/ssr/PanelRuntime.island.tsx +94 -0
- package/src/ssr/PanelSlot.tsx +158 -0
- package/src/ssr/PanelTrigger.tsx +49 -0
- package/src/ssr/ShellRuntime.island.tsx +69 -5
- package/src/ssr/app-appearance.ts +3 -2
- package/src/ssr/app-color.ts +105 -0
- package/src/ssr/app-launchpad.tsx +18 -1
- package/src/ssr/app-navigation.ts +28 -0
- package/src/ssr/compact-shell.baseline.json +5270 -0
- package/src/ssr/compact-shell.browser-harness.ts +439 -0
- package/src/ssr/dialog-app-colors.browser-harness.tsx +43 -0
- package/src/ssr/help-messages.ts +4 -0
- package/src/ssr/help.ts +20 -0
- package/src/ssr/hints.ts +25 -0
- package/src/ssr/index.ts +5 -0
- package/src/ssr/islands/index.ts +4 -3
- package/src/ssr/layout-context.ts +2 -0
- package/src/ssr/layout-help-article.server.ts +50 -0
- package/src/ssr/layout-help-start.ts +33 -7
- package/src/ssr/panel-consent.ts +29 -0
- package/src/ssr/panel-context.ts +10 -0
- package/src/ssr/panel-slot.browser-harness.tsx +159 -0
- package/src/ssr/platform-messages.ts +48 -0
- package/src/ssr/rail-navigation.ts +44 -0
- package/src/ssr/shell-data.tsx +5 -0
- package/src/ssr/shell-shortcuts.ts +13 -2
- package/src/styles/effects.css +201 -11
- package/src/styles/file-chooser.css +2 -1
- package/src/styles/global.css +314 -63
- package/src/styles/panel.css +416 -0
- package/src/styles/resource-search.css +6 -2
- package/src/styles/tokens.css +36 -13
- package/src/styles/utilities-buttons.css +3 -2
- package/src/styles/utilities-data.css +2 -1
- package/src/styles/utilities-feedback.css +2 -2
- package/src/styles/utilities-layout.css +3 -13
- package/src/styles/utilities-markdown-editor.css +6 -6
- package/src/styles/utilities-navigation.css +22 -43
|
@@ -0,0 +1,756 @@
|
|
|
1
|
+
import { attachResizeSeparator, LocaleProvider, parkWorkspaceDetailDrawers } from "@k2b/ui";
|
|
2
|
+
import { createComponent } from "solid-js";
|
|
3
|
+
import { render } from "solid-js/web";
|
|
4
|
+
import { PANEL_MAX_WIDTH_REM, PANEL_MIN_WIDTH_REM, readPanelCookie, serializePanelCookie } from "../_internal/panel-cookie";
|
|
5
|
+
import { CloudResourceRefSchema } from "../contracts/capabilities";
|
|
6
|
+
import type { PanelPageContext } from "../contracts/panel-context";
|
|
7
|
+
import { isPanelShortcut, PANEL_CONTEXT_PARAM, panelProviderNeedsConsent, withPanelContext } from "../contracts/panel-context-plain";
|
|
8
|
+
import { helpShortcutGroups } from "../ssr/layout-help-start";
|
|
9
|
+
import { platformMessages } from "../ssr/platform-messages";
|
|
10
|
+
import { readShellData } from "../ssr/shell-data";
|
|
11
|
+
import { COMMANDS_CHANGED, contextCommandsWithShortcuts } from "./command-shortcuts";
|
|
12
|
+
import type { PanelRequest } from "./panel";
|
|
13
|
+
import {
|
|
14
|
+
PAGE_CONTEXT_CHANGED,
|
|
15
|
+
PAGE_CONTEXT_COLLECT,
|
|
16
|
+
type PageContextCollection,
|
|
17
|
+
type PanelConsentRequest,
|
|
18
|
+
type PanelState,
|
|
19
|
+
} from "./panel-context-bridge";
|
|
20
|
+
import PanelMenu from "./panel-menu";
|
|
21
|
+
import { isPanelCloseMessage, isPanelReadyMessage, isPanelSearchMessage } from "./panel-messages";
|
|
22
|
+
import { importOnDemand } from "./reload";
|
|
23
|
+
|
|
24
|
+
export const PANEL_READY_TIMEOUT_MS = 10_000;
|
|
25
|
+
let installed: { dispose: () => void; request: (request: PanelRequest) => void } | undefined;
|
|
26
|
+
|
|
27
|
+
export function handlePanelRequest(request: PanelRequest): void {
|
|
28
|
+
installed?.request(request);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Owns the slot's single iframe, independently persisted desktop and transient phone state. */
|
|
32
|
+
export function installPanelSlot(): () => void {
|
|
33
|
+
if (installed) return installed.dispose;
|
|
34
|
+
const canvas = document.querySelector<HTMLElement>(".cloud-app-canvas");
|
|
35
|
+
const slot = document.getElementById("cloud-panel");
|
|
36
|
+
const body = slot?.querySelector<HTMLElement>(".cloud-panel__body");
|
|
37
|
+
const panel = readShellData()?.panel;
|
|
38
|
+
if (!canvas || !slot || !body || !panel?.providers.length) return () => {};
|
|
39
|
+
const lang = document.documentElement.lang || "en";
|
|
40
|
+
const t = platformMessages.resolve([lang]).t;
|
|
41
|
+
const menuHost = slot.querySelector<HTMLElement>("[data-cloud-panel-menu]");
|
|
42
|
+
// The mode uses the same px breakpoint as panel.css and the shell, read fresh, so CSS and the controller always
|
|
43
|
+
// agree; `phoneChange` only tells when to look again.
|
|
44
|
+
const isPhone = () => !window.matchMedia("(min-width: 768px)").matches;
|
|
45
|
+
const phoneChange = window.matchMedia("(max-width: 767.98px)");
|
|
46
|
+
const column = window.matchMedia("(min-width: 1024px)");
|
|
47
|
+
const attribute = () => (isPhone() ? "data-panel-sheet" : "data-panel");
|
|
48
|
+
const isOpen = () => canvas.getAttribute(attribute()) === "open";
|
|
49
|
+
const status = slot.querySelector<HTMLElement>("[data-cloud-panel-status]");
|
|
50
|
+
const separator = slot.querySelector<HTMLElement>(".cloud-panel__resize");
|
|
51
|
+
const tabs = [...slot.querySelectorAll<HTMLButtonElement>("[data-cloud-panel-provider]")];
|
|
52
|
+
const grow = slot.querySelector<HTMLButtonElement>("[data-cloud-panel-grow]");
|
|
53
|
+
let provider = panel.providers.find((item) => item.id === slot.dataset.provider) ?? panel.providers[0]!;
|
|
54
|
+
let frame = slot.querySelector<HTMLIFrameElement>("[data-cloud-panel-frame]");
|
|
55
|
+
let disposed = false;
|
|
56
|
+
let context = panel.context ?? null;
|
|
57
|
+
let loadVersion = 0;
|
|
58
|
+
const answers = new Map<string, boolean | null>();
|
|
59
|
+
const revisions = new Map<string, number>();
|
|
60
|
+
if (panel.consent) {
|
|
61
|
+
answers.set(panel.consent.provider, panel.consent.allowed);
|
|
62
|
+
revisions.set(panel.consent.provider, panel.consent.revision);
|
|
63
|
+
}
|
|
64
|
+
const saving = new Set<string>();
|
|
65
|
+
const errors = new Set<string>();
|
|
66
|
+
const requests = new AbortController();
|
|
67
|
+
/*
|
|
68
|
+
* Consent reads and writes run one at a time, in order, and sharing with a provider that needs consent is blocked
|
|
69
|
+
* while any of them is pending or the answers are stale (a back/forward restore). Server revisions also fence
|
|
70
|
+
* writes still running after a client timeout; only a settled, current `true` lets context through.
|
|
71
|
+
*/
|
|
72
|
+
let consentQueue: Promise<void> = Promise.resolve();
|
|
73
|
+
let consentPending = 0;
|
|
74
|
+
let answersStale = false;
|
|
75
|
+
// A save that failed or timed out has an unknown outcome on the server: nothing is shared until the person answers again.
|
|
76
|
+
const allowed = () =>
|
|
77
|
+
!provider.consent || (consentPending === 0 && !answersStale && !errors.has(provider.id) && answers.get(provider.id) === true);
|
|
78
|
+
// Whether the current frame has seen a context, in its URL or by message; only such a frame needs a clearing null.
|
|
79
|
+
let disclosed = !!frame && new URL(frame.src, location.origin).searchParams.has(PANEL_CONTEXT_PARAM);
|
|
80
|
+
// The frame may have navigated on its own; only the selected provider's page below its panel path may receive context.
|
|
81
|
+
const showsProvider = () => {
|
|
82
|
+
try {
|
|
83
|
+
const shown = frame?.contentWindow?.location;
|
|
84
|
+
const scope = new URL(provider.href, location.origin).pathname.replace(/\/$/, "");
|
|
85
|
+
return (
|
|
86
|
+
!!shown &&
|
|
87
|
+
frame?.contentDocument?.documentElement.dataset.cloudPanelReady === provider.id &&
|
|
88
|
+
(shown.pathname === scope || shown.pathname.startsWith(`${scope}/`))
|
|
89
|
+
);
|
|
90
|
+
} catch {
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
const shortcuts = () =>
|
|
95
|
+
helpShortcutGroups(contextCommandsWithShortcuts())
|
|
96
|
+
.flatMap((group) =>
|
|
97
|
+
group.shortcuts.map(({ id, title, description, shortcut, scope }) => ({
|
|
98
|
+
id,
|
|
99
|
+
title,
|
|
100
|
+
...(description ? { description } : {}),
|
|
101
|
+
shortcut,
|
|
102
|
+
...(scope === "selection" || scope === "page" ? { scope } : {}),
|
|
103
|
+
})),
|
|
104
|
+
)
|
|
105
|
+
.filter(isPanelShortcut);
|
|
106
|
+
let lastShortcutsJson: string | undefined;
|
|
107
|
+
const canSend = () => isOpen() && allowed() && !!context && slot.dataset.state === "ready" && !!frame?.contentWindow && showsProvider();
|
|
108
|
+
const sendContext = () => {
|
|
109
|
+
if (!canSend()) return;
|
|
110
|
+
const list = !panelProviderNeedsConsent(provider.id) ? shortcuts() : undefined;
|
|
111
|
+
frame?.contentWindow?.postMessage(
|
|
112
|
+
{ type: "cloud.panel.context", v: 1, context, ...(list ? { shortcuts: list } : {}) },
|
|
113
|
+
location.origin,
|
|
114
|
+
);
|
|
115
|
+
if (list) lastShortcutsJson = JSON.stringify(list);
|
|
116
|
+
disclosed = true;
|
|
117
|
+
// A reload of the provider page renders from its address, so the address follows the live context.
|
|
118
|
+
syncUrlContext(context);
|
|
119
|
+
};
|
|
120
|
+
const postNull = () => {
|
|
121
|
+
frame?.contentWindow?.postMessage({ type: "cloud.panel.context", v: 1, context: null }, location.origin);
|
|
122
|
+
lastShortcutsJson = undefined;
|
|
123
|
+
};
|
|
124
|
+
let shortcutsPending = false;
|
|
125
|
+
const commandsChanged = () => {
|
|
126
|
+
if (shortcutsPending) return;
|
|
127
|
+
shortcutsPending = true;
|
|
128
|
+
queueMicrotask(() => {
|
|
129
|
+
shortcutsPending = false;
|
|
130
|
+
if (disposed || panelProviderNeedsConsent(provider.id) || !canSend()) return;
|
|
131
|
+
if (JSON.stringify(shortcuts()) !== lastShortcutsJson) sendContext();
|
|
132
|
+
});
|
|
133
|
+
};
|
|
134
|
+
const clearContext = () => {
|
|
135
|
+
if (disclosed) postNull();
|
|
136
|
+
};
|
|
137
|
+
// The provider's address carries the snapshot its server renders on reload: it follows sharing in both directions.
|
|
138
|
+
// True when the provider's current address now matches; false while its document is not reachable yet.
|
|
139
|
+
function syncUrlContext(next: PanelPageContext | null): boolean {
|
|
140
|
+
try {
|
|
141
|
+
const shown = frame?.contentWindow;
|
|
142
|
+
if (!shown || (next && !showsProvider())) return false;
|
|
143
|
+
const current = `${shown.location.pathname}${shown.location.search}${shown.location.hash}`;
|
|
144
|
+
const path = withPanelContext(current, next);
|
|
145
|
+
if (path !== current) shown.history.replaceState(shown.history.state, "", path);
|
|
146
|
+
return true;
|
|
147
|
+
} catch {
|
|
148
|
+
// Not yet a same-origin document (about:blank, a pending navigation); its ready handshake clears it.
|
|
149
|
+
return false;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
// A withdrawn answer must not come back when the provider reloads its own document: the URL snapshot goes too.
|
|
153
|
+
const forgetUrlContext = () => syncUrlContext(null);
|
|
154
|
+
const stopSharing = () => {
|
|
155
|
+
if (!disclosed) return;
|
|
156
|
+
postNull();
|
|
157
|
+
// Only a frame whose address is clean holds nothing any more; otherwise ready cleans it up later.
|
|
158
|
+
if (forgetUrlContext()) disclosed = false;
|
|
159
|
+
};
|
|
160
|
+
const collectContext = () => {
|
|
161
|
+
if (!context) return;
|
|
162
|
+
const collection: PageContextCollection = { sources: [] };
|
|
163
|
+
window.dispatchEvent(new CustomEvent(PAGE_CONTEXT_COLLECT, { detail: collection }));
|
|
164
|
+
const source = collection.sources.findLast((source) => source.scope !== undefined);
|
|
165
|
+
const parsed = source ? CloudResourceRefSchema.safeParse(source.scope) : undefined;
|
|
166
|
+
const scope = source ? (parsed?.success ? parsed.data : undefined) : panel.context?.scope;
|
|
167
|
+
if (scope?.type === context.scope?.type && scope?.id === context.scope?.id) return;
|
|
168
|
+
const { scope: _previous, ...base } = context;
|
|
169
|
+
context = { ...base, seq: context.seq + 1, ...(scope ? { scope } : {}) };
|
|
170
|
+
sendContext();
|
|
171
|
+
};
|
|
172
|
+
// Share or stop sharing after every consent step; an open question or its absence follows in update().
|
|
173
|
+
const reconcile = () => {
|
|
174
|
+
if (disposed) return;
|
|
175
|
+
update();
|
|
176
|
+
if (allowed()) sendContext();
|
|
177
|
+
else if (provider.consent) stopSharing();
|
|
178
|
+
};
|
|
179
|
+
const enqueueConsent = (step: () => Promise<void>) => {
|
|
180
|
+
consentPending += 1;
|
|
181
|
+
reconcile();
|
|
182
|
+
const run = consentQueue.then(step).finally(() => {
|
|
183
|
+
consentPending -= 1;
|
|
184
|
+
reconcile();
|
|
185
|
+
});
|
|
186
|
+
consentQueue = run.catch(() => {});
|
|
187
|
+
return run;
|
|
188
|
+
};
|
|
189
|
+
// Replaces every answer of providers that need consent; a failed read leaves them unanswered (fail closed).
|
|
190
|
+
const readAnswersNow = async () => {
|
|
191
|
+
const next = new Map<string, boolean | null>();
|
|
192
|
+
const nextRevisions = new Map<string, number>();
|
|
193
|
+
try {
|
|
194
|
+
const response = await fetch("/api/me/panel-consent", {
|
|
195
|
+
cache: "no-store",
|
|
196
|
+
signal: AbortSignal.any([requests.signal, AbortSignal.timeout(PANEL_READY_TIMEOUT_MS)]),
|
|
197
|
+
});
|
|
198
|
+
if (!response.ok) throw new Error("Panel consent unavailable");
|
|
199
|
+
const data: unknown = await response.json();
|
|
200
|
+
if (data && typeof data === "object" && "answers" in data && data.answers && typeof data.answers === "object") {
|
|
201
|
+
for (const item of panel.providers) {
|
|
202
|
+
const value: unknown = Reflect.get(data.answers, item.id);
|
|
203
|
+
if (
|
|
204
|
+
value &&
|
|
205
|
+
typeof value === "object" &&
|
|
206
|
+
"allowed" in value &&
|
|
207
|
+
typeof value.allowed === "boolean" &&
|
|
208
|
+
"revision" in value &&
|
|
209
|
+
typeof value.revision === "number" &&
|
|
210
|
+
Number.isInteger(value.revision) &&
|
|
211
|
+
value.revision > 0
|
|
212
|
+
) {
|
|
213
|
+
next.set(item.id, value.allowed);
|
|
214
|
+
nextRevisions.set(item.id, value.revision);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
} catch {
|
|
219
|
+
// No answer is fail closed, including an unavailable store or an aborted request.
|
|
220
|
+
}
|
|
221
|
+
for (const item of panel.providers) {
|
|
222
|
+
if (!item.consent) continue;
|
|
223
|
+
answers.set(item.id, next.get(item.id) ?? null);
|
|
224
|
+
revisions.set(item.id, nextRevisions.get(item.id) ?? 0);
|
|
225
|
+
}
|
|
226
|
+
answersStale = false;
|
|
227
|
+
};
|
|
228
|
+
let answersRead: Promise<void> | undefined;
|
|
229
|
+
const readAnswers = () => {
|
|
230
|
+
answersRead ??= enqueueConsent(readAnswersNow);
|
|
231
|
+
return answersRead;
|
|
232
|
+
};
|
|
233
|
+
// A restored page may hold answers changed elsewhere meanwhile: stop sharing now and read them again in order.
|
|
234
|
+
const revalidate = () => {
|
|
235
|
+
answersStale = true;
|
|
236
|
+
answersRead = enqueueConsent(readAnswersNow);
|
|
237
|
+
return answersRead;
|
|
238
|
+
};
|
|
239
|
+
let search: string | undefined;
|
|
240
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
241
|
+
let opener: HTMLElement | undefined;
|
|
242
|
+
const rem = () => parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
|
|
243
|
+
const storedWidth = parseFloat(canvas.style.getPropertyValue("--cloud-panel-width-set"));
|
|
244
|
+
let committedWidth = Number.isFinite(storedWidth) ? storedWidth : undefined;
|
|
245
|
+
let width = committedWidth ?? PANEL_MIN_WIDTH_REM;
|
|
246
|
+
const setWidth = (value: number) => {
|
|
247
|
+
width = value;
|
|
248
|
+
canvas.style.setProperty("--cloud-panel-width-set", `${value}rem`);
|
|
249
|
+
separator?.setAttribute("aria-valuenow", String(Math.round(value * 100) / 100));
|
|
250
|
+
};
|
|
251
|
+
const seedWidth = () => {
|
|
252
|
+
if (committedWidth !== undefined || !column.matches || !isOpen()) return;
|
|
253
|
+
const measured = slot.getBoundingClientRect().width / rem();
|
|
254
|
+
if (measured > 0) width = Math.min(PANEL_MAX_WIDTH_REM, Math.max(PANEL_MIN_WIDTH_REM, measured));
|
|
255
|
+
separator?.setAttribute("aria-valuenow", String(Math.round(width * 100) / 100));
|
|
256
|
+
};
|
|
257
|
+
// Another tab may have saved a newer width; opening or closing keeps it instead of writing back an old one.
|
|
258
|
+
const adoptStoredWidth = (apply: boolean) => {
|
|
259
|
+
const stored = readPanelCookie(
|
|
260
|
+
document.cookie,
|
|
261
|
+
panel.providers.map((item) => item.id),
|
|
262
|
+
).widthRem;
|
|
263
|
+
if (stored === undefined) return;
|
|
264
|
+
committedWidth = stored;
|
|
265
|
+
if (apply && stored !== width) setWidth(stored);
|
|
266
|
+
};
|
|
267
|
+
const writeCookie = () => {
|
|
268
|
+
if (isPhone() || canvas.hasAttribute("data-panel-transient")) return;
|
|
269
|
+
document.cookie = serializePanelCookie(
|
|
270
|
+
{ open: canvas.dataset.panel === "open", provider: provider.id, widthRem: committedWidth },
|
|
271
|
+
location.protocol === "https:",
|
|
272
|
+
);
|
|
273
|
+
};
|
|
274
|
+
const update = () => {
|
|
275
|
+
for (const button of document.querySelectorAll("[data-cloud-panel-toggle]")) button.setAttribute("aria-expanded", String(isOpen()));
|
|
276
|
+
for (const tab of tabs) {
|
|
277
|
+
const selected = tab.dataset.cloudPanelProvider === provider.id;
|
|
278
|
+
tab.setAttribute("aria-selected", String(selected));
|
|
279
|
+
tab.tabIndex = selected ? 0 : -1;
|
|
280
|
+
}
|
|
281
|
+
slot.dataset.appName = provider.appName;
|
|
282
|
+
slot.dataset.providerConsent = String(provider.consent);
|
|
283
|
+
slot.dataset.contextAllowed = String(allowed());
|
|
284
|
+
slot.dataset.consentSaving = String(saving.has(provider.id));
|
|
285
|
+
const ask =
|
|
286
|
+
isOpen() && provider.consent && ((answers.has(provider.id) && answers.get(provider.id) === null) || errors.has(provider.id));
|
|
287
|
+
if (ask) slot.dataset.consent = "ask";
|
|
288
|
+
else delete slot.dataset.consent;
|
|
289
|
+
const title = slot.querySelector<HTMLElement>("[data-cloud-panel-consent-title]");
|
|
290
|
+
const text = slot.querySelector<HTMLElement>("[data-cloud-panel-consent-body]");
|
|
291
|
+
const error = slot.querySelector<HTMLElement>("[data-cloud-panel-consent-error]");
|
|
292
|
+
if (title) title.textContent = t.panelConsentTitle({ app: provider.appName });
|
|
293
|
+
if (text) text.textContent = t.panelConsentBody({ app: provider.appName });
|
|
294
|
+
if (error) error.textContent = errors.has(provider.id) ? t.panelConsentSaveError : "";
|
|
295
|
+
for (const button of slot.querySelectorAll<HTMLButtonElement>("[data-cloud-panel-consent-answer]"))
|
|
296
|
+
button.disabled = saving.has(provider.id);
|
|
297
|
+
if (menuHost) {
|
|
298
|
+
menuHost.style.visibility = provider.consent ? "visible" : "hidden";
|
|
299
|
+
menuHost.inert = !provider.consent;
|
|
300
|
+
}
|
|
301
|
+
window.dispatchEvent(
|
|
302
|
+
new CustomEvent<PanelState>("cloud.panel.state", {
|
|
303
|
+
detail: {
|
|
304
|
+
provider: provider.id,
|
|
305
|
+
appName: provider.appName,
|
|
306
|
+
consent: provider.consent,
|
|
307
|
+
allowed: allowed(),
|
|
308
|
+
saving: saving.has(provider.id),
|
|
309
|
+
open: isOpen(),
|
|
310
|
+
},
|
|
311
|
+
}),
|
|
312
|
+
);
|
|
313
|
+
const description = slot.querySelector<HTMLElement>(".k2b-placeholder__description");
|
|
314
|
+
if (description) description.textContent = t.panelNotResponding({ label: provider.label });
|
|
315
|
+
};
|
|
316
|
+
const clearTimer = () => {
|
|
317
|
+
clearTimeout(timer);
|
|
318
|
+
timer = undefined;
|
|
319
|
+
};
|
|
320
|
+
const loading = () => {
|
|
321
|
+
clearTimer();
|
|
322
|
+
slot.dataset.state = "loading";
|
|
323
|
+
if (status) status.textContent = t.panelLoading({ label: provider.label });
|
|
324
|
+
timer = setTimeout(() => {
|
|
325
|
+
slot.dataset.state = "failed";
|
|
326
|
+
if (status) status.textContent = `${t.panelLoadFailed}. ${t.panelNotResponding({ label: provider.label })}`;
|
|
327
|
+
}, PANEL_READY_TIMEOUT_MS);
|
|
328
|
+
};
|
|
329
|
+
const markReady = () => {
|
|
330
|
+
clearTimer();
|
|
331
|
+
slot.dataset.state = "ready";
|
|
332
|
+
if (status) status.textContent = "";
|
|
333
|
+
if (allowed()) {
|
|
334
|
+
sendContext();
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
// The arriving document may carry a snapshot this controller never cleaned (a navigation that was already under
|
|
338
|
+
// way at withdrawal, a reload): look at the document itself, not only at what the last one saw.
|
|
339
|
+
let carries = disclosed;
|
|
340
|
+
try {
|
|
341
|
+
carries ||= new URL(frame?.contentWindow?.location.href ?? "about:blank").searchParams.has(PANEL_CONTEXT_PARAM);
|
|
342
|
+
} catch {
|
|
343
|
+
carries = true;
|
|
344
|
+
}
|
|
345
|
+
if (carries) {
|
|
346
|
+
postNull();
|
|
347
|
+
disclosed = !forgetUrlContext();
|
|
348
|
+
}
|
|
349
|
+
};
|
|
350
|
+
const unload = () => {
|
|
351
|
+
++loadVersion;
|
|
352
|
+
focusPending = false;
|
|
353
|
+
clearContext();
|
|
354
|
+
clearTimer();
|
|
355
|
+
frame?.remove();
|
|
356
|
+
frame = null;
|
|
357
|
+
lastShortcutsJson = undefined;
|
|
358
|
+
slot.dataset.state = "closed";
|
|
359
|
+
if (status) status.textContent = "";
|
|
360
|
+
};
|
|
361
|
+
const target = () => {
|
|
362
|
+
const url = new URL(provider.href, location.origin);
|
|
363
|
+
if (search !== undefined) url.search = search;
|
|
364
|
+
if (url.origin === location.origin) {
|
|
365
|
+
const path = withPanelContext(`${url.pathname}${url.search}${url.hash}`, allowed() ? context : null);
|
|
366
|
+
return new URL(path, location.origin);
|
|
367
|
+
}
|
|
368
|
+
return url;
|
|
369
|
+
};
|
|
370
|
+
// The page the iframe shows now; a reader may have moved on inside the panel since it loaded.
|
|
371
|
+
const shownHref = () => {
|
|
372
|
+
try {
|
|
373
|
+
return frame?.contentWindow?.location.href;
|
|
374
|
+
} catch {
|
|
375
|
+
return undefined;
|
|
376
|
+
}
|
|
377
|
+
};
|
|
378
|
+
// The context snapshot in the URL is not the provider's destination: a frame that got newer context live stays.
|
|
379
|
+
const destination = (href: string) => {
|
|
380
|
+
const url = new URL(href, location.origin);
|
|
381
|
+
return url.origin === location.origin ? withPanelContext(`${url.pathname}${url.search}${url.hash}`, null) : url.href;
|
|
382
|
+
};
|
|
383
|
+
const loadNow = (replace = false) => {
|
|
384
|
+
const url = target();
|
|
385
|
+
if (!replace && frame && destination(frame.src) === destination(url.href)) return;
|
|
386
|
+
clearContext();
|
|
387
|
+
frame?.remove();
|
|
388
|
+
lastShortcutsJson = undefined;
|
|
389
|
+
// The server validates every provider path; the iframe still never leaves this origin.
|
|
390
|
+
if (url.origin !== location.origin) {
|
|
391
|
+
frame = null;
|
|
392
|
+
clearTimer();
|
|
393
|
+
slot.dataset.state = "failed";
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
frame = document.createElement("iframe");
|
|
397
|
+
frame.className = "cloud-panel__frame";
|
|
398
|
+
frame.title = provider.label;
|
|
399
|
+
frame.dataset.cloudPanelFrame = provider.id;
|
|
400
|
+
frame.src = url.href;
|
|
401
|
+
disclosed = url.searchParams.has(PANEL_CONTEXT_PARAM);
|
|
402
|
+
loading();
|
|
403
|
+
body.append(frame);
|
|
404
|
+
};
|
|
405
|
+
const load = (replace = false) => {
|
|
406
|
+
const version = ++loadVersion;
|
|
407
|
+
if (provider.consent && !answers.has(provider.id)) {
|
|
408
|
+
clearContext();
|
|
409
|
+
frame?.remove();
|
|
410
|
+
frame = null;
|
|
411
|
+
lastShortcutsJson = undefined;
|
|
412
|
+
loading();
|
|
413
|
+
clearTimer(); // The consent read has its own bounded timeout; provider readiness starts with its load.
|
|
414
|
+
void readAnswers().then(() => {
|
|
415
|
+
if (disposed || !isOpen() || version !== loadVersion) return;
|
|
416
|
+
loadNow(replace);
|
|
417
|
+
update();
|
|
418
|
+
// open() could not move focus while nothing was there; do it now unless the reader moved on meanwhile.
|
|
419
|
+
const active = document.activeElement;
|
|
420
|
+
if (focusPending && (active === document.body || active === opener || slot.contains(active))) focusPanel();
|
|
421
|
+
focusPending = false;
|
|
422
|
+
});
|
|
423
|
+
} else loadNow(replace);
|
|
424
|
+
};
|
|
425
|
+
const saveConsent = (request: PanelConsentRequest) => {
|
|
426
|
+
const item = panel.providers.find((item) => item.id === request.provider && item.consent);
|
|
427
|
+
if (!item || typeof request.allowed !== "boolean" || saving.has(item.id)) return;
|
|
428
|
+
saving.add(item.id);
|
|
429
|
+
errors.delete(item.id);
|
|
430
|
+
const questionHadFocus = slot.querySelector("[data-cloud-panel-consent]")?.contains(document.activeElement);
|
|
431
|
+
// Enqueueing blocks sharing at once, so a withdrawal stops disclosure before its save even starts.
|
|
432
|
+
// keepalive: leaving the page does not cancel the save. A save the browser gives up on (timeout) is rolled back
|
|
433
|
+
// by the server, so no answer lands after this page stopped waiting for it.
|
|
434
|
+
const put = () =>
|
|
435
|
+
fetch(`/api/me/panel-consent/${encodeURIComponent(item.id)}`, {
|
|
436
|
+
method: "PUT",
|
|
437
|
+
headers: { "Content-Type": "application/json" },
|
|
438
|
+
body: JSON.stringify({ allowed: request.allowed, revision: revisions.get(item.id) ?? 0 }),
|
|
439
|
+
keepalive: true,
|
|
440
|
+
signal: AbortSignal.timeout(PANEL_READY_TIMEOUT_MS),
|
|
441
|
+
});
|
|
442
|
+
void enqueueConsent(async () => {
|
|
443
|
+
try {
|
|
444
|
+
let response = await put();
|
|
445
|
+
// Changed meanwhile (another tab, or a read that failed): the person's choice now is the newest one, so read
|
|
446
|
+
// the current revision and save it once more. A second conflict shows the question again.
|
|
447
|
+
if (response.status === 409) {
|
|
448
|
+
await readAnswersNow();
|
|
449
|
+
response = await put();
|
|
450
|
+
}
|
|
451
|
+
if (!response.ok) throw new Error("Panel consent was not saved");
|
|
452
|
+
const saved: unknown = await response.json();
|
|
453
|
+
if (
|
|
454
|
+
!saved ||
|
|
455
|
+
typeof saved !== "object" ||
|
|
456
|
+
!("provider" in saved) ||
|
|
457
|
+
saved.provider !== item.id ||
|
|
458
|
+
!("allowed" in saved) ||
|
|
459
|
+
saved.allowed !== request.allowed ||
|
|
460
|
+
!("revision" in saved) ||
|
|
461
|
+
typeof saved.revision !== "number" ||
|
|
462
|
+
!Number.isInteger(saved.revision) ||
|
|
463
|
+
saved.revision <= 0
|
|
464
|
+
)
|
|
465
|
+
throw new Error("Invalid panel consent answer");
|
|
466
|
+
answers.set(item.id, request.allowed);
|
|
467
|
+
revisions.set(item.id, saved.revision);
|
|
468
|
+
} catch {
|
|
469
|
+
// Not saved: nothing is shared until the person answers again; the read learns the current revision.
|
|
470
|
+
errors.add(item.id);
|
|
471
|
+
await readAnswersNow();
|
|
472
|
+
answers.set(item.id, null);
|
|
473
|
+
} finally {
|
|
474
|
+
saving.delete(item.id);
|
|
475
|
+
}
|
|
476
|
+
}).then(() => {
|
|
477
|
+
if (disposed) return;
|
|
478
|
+
// Move on into the provider only if the reader is still at the question (or nowhere), not after they moved on.
|
|
479
|
+
const active = document.activeElement;
|
|
480
|
+
const stillHere = active === document.body || !!slot.querySelector("[data-cloud-panel-consent]")?.contains(active);
|
|
481
|
+
if (provider.id === item.id && isOpen() && questionHadFocus && stillHere && slot.dataset.consent !== "ask") frame?.focus();
|
|
482
|
+
});
|
|
483
|
+
};
|
|
484
|
+
const consentRequest = (event: CustomEvent<PanelConsentRequest>) => {
|
|
485
|
+
void saveConsent(event.detail);
|
|
486
|
+
};
|
|
487
|
+
let focusPending = false;
|
|
488
|
+
// An open question takes focus first, so the reader hears it before the provider page.
|
|
489
|
+
const focusPanel = () => {
|
|
490
|
+
if (slot.dataset.consent === "ask") slot.querySelector<HTMLElement>("[data-cloud-panel-consent]")?.focus();
|
|
491
|
+
else frame?.focus();
|
|
492
|
+
};
|
|
493
|
+
let removeFallbackFocus = () => {};
|
|
494
|
+
const returnFocus = () => {
|
|
495
|
+
if (opener?.isConnected && opener.getClientRects().length && !opener.closest("[inert]")) {
|
|
496
|
+
opener.focus();
|
|
497
|
+
if (document.activeElement === opener) return;
|
|
498
|
+
}
|
|
499
|
+
const main = canvas.querySelector<HTMLElement>("[data-cloud-app-area] main.layout-content-main, main[data-cloud-app-area]");
|
|
500
|
+
if (!main) return;
|
|
501
|
+
removeFallbackFocus();
|
|
502
|
+
if (!main.hasAttribute("tabindex")) {
|
|
503
|
+
main.tabIndex = -1;
|
|
504
|
+
removeFallbackFocus = () => {
|
|
505
|
+
main.removeAttribute("tabindex");
|
|
506
|
+
main.removeEventListener("blur", removeFallbackFocus);
|
|
507
|
+
};
|
|
508
|
+
main.addEventListener("blur", removeFallbackFocus, { once: true });
|
|
509
|
+
}
|
|
510
|
+
main.focus();
|
|
511
|
+
};
|
|
512
|
+
const close = () => {
|
|
513
|
+
if (!isOpen()) return;
|
|
514
|
+
canvas.removeAttribute(attribute());
|
|
515
|
+
disposeResize();
|
|
516
|
+
disposeResize = attachResize();
|
|
517
|
+
unload();
|
|
518
|
+
adoptStoredWidth(false);
|
|
519
|
+
writeCookie();
|
|
520
|
+
update();
|
|
521
|
+
returnFocus();
|
|
522
|
+
};
|
|
523
|
+
const open = (id: string, nextSearch?: string) => {
|
|
524
|
+
const next = panel.providers.find((item) => item.id === id);
|
|
525
|
+
if (!next) return;
|
|
526
|
+
parkWorkspaceDetailDrawers(document);
|
|
527
|
+
const active = document.activeElement;
|
|
528
|
+
if (active instanceof HTMLElement && active !== document.body && !slot.contains(active)) opener = active;
|
|
529
|
+
if (provider.id !== next.id) unload();
|
|
530
|
+
provider = next;
|
|
531
|
+
search = typeof nextSearch === "string" && nextSearch.length <= 2048 ? nextSearch : undefined;
|
|
532
|
+
canvas.setAttribute(attribute(), "open");
|
|
533
|
+
slot.dataset.provider = provider.id;
|
|
534
|
+
adoptStoredWidth(true);
|
|
535
|
+
seedWidth();
|
|
536
|
+
// A requested search is a destination: it replaces a page reached inside the panel.
|
|
537
|
+
const shown = shownHref();
|
|
538
|
+
focusPending = false;
|
|
539
|
+
load(search !== undefined && shown !== undefined && shown !== "about:blank" && destination(shown) !== destination(target().href));
|
|
540
|
+
writeCookie();
|
|
541
|
+
update();
|
|
542
|
+
if (frame) focusPanel();
|
|
543
|
+
else focusPending = true;
|
|
544
|
+
};
|
|
545
|
+
const request = (request: PanelRequest) => {
|
|
546
|
+
if (request.action === "open") open(request.provider, request.search);
|
|
547
|
+
else if (request.action === "close" || isOpen()) close();
|
|
548
|
+
else open(provider.id, search);
|
|
549
|
+
};
|
|
550
|
+
const message = (event: MessageEvent<unknown>) => {
|
|
551
|
+
if (!frame || event.source !== frame.contentWindow || event.origin !== location.origin) return;
|
|
552
|
+
if (isPanelReadyMessage(event.data, provider.id)) markReady();
|
|
553
|
+
else if (isPanelCloseMessage(event.data)) close();
|
|
554
|
+
else if (isPanelSearchMessage(event.data)) {
|
|
555
|
+
const command = contextCommandsWithShortcuts().find((command) => command.id === "cloud.search" && command.shortcut);
|
|
556
|
+
if (command) void importOnDemand(() => import("./commands")).then((commands) => commands?.runContextAwareCommand(command));
|
|
557
|
+
}
|
|
558
|
+
};
|
|
559
|
+
const click = (event: MouseEvent) => {
|
|
560
|
+
if (!(event.target instanceof Element)) return;
|
|
561
|
+
const answer = event.target.closest<HTMLElement>("[data-cloud-panel-consent-answer]");
|
|
562
|
+
if (answer && isOpen()) void saveConsent({ provider: provider.id, allowed: answer.dataset.cloudPanelConsentAnswer === "allow" });
|
|
563
|
+
else if (event.target.closest("[data-cloud-panel-close]")) close();
|
|
564
|
+
else if (event.target.closest("[data-cloud-panel-retry]") && isOpen()) {
|
|
565
|
+
load(true);
|
|
566
|
+
frame?.focus();
|
|
567
|
+
} else if (event.target.closest("[data-cloud-panel-grow]") && isPhone()) {
|
|
568
|
+
const full = slot.dataset.panelSize !== "full";
|
|
569
|
+
if (full) slot.dataset.panelSize = "full";
|
|
570
|
+
else delete slot.dataset.panelSize;
|
|
571
|
+
grow?.setAttribute("aria-label", full ? t.shrinkPanel : t.enlargePanel);
|
|
572
|
+
} else {
|
|
573
|
+
const tab = event.target.closest<HTMLElement>("[data-cloud-panel-provider]");
|
|
574
|
+
if (tab?.dataset.cloudPanelProvider) open(tab.dataset.cloudPanelProvider);
|
|
575
|
+
}
|
|
576
|
+
};
|
|
577
|
+
const key = (event: KeyboardEvent) => {
|
|
578
|
+
if (event.defaultPrevented || event.isComposing || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return;
|
|
579
|
+
if (!(event.target instanceof Element) || !slot.contains(event.target)) return;
|
|
580
|
+
if (event.key === "Escape") {
|
|
581
|
+
event.preventDefault();
|
|
582
|
+
close();
|
|
583
|
+
return;
|
|
584
|
+
}
|
|
585
|
+
const index = tabs.findIndex((tab) => tab === event.target);
|
|
586
|
+
if (index < 0) return;
|
|
587
|
+
let next: number;
|
|
588
|
+
if (event.key === "ArrowRight") next = (index + 1) % tabs.length;
|
|
589
|
+
else if (event.key === "ArrowLeft") next = (index + tabs.length - 1) % tabs.length;
|
|
590
|
+
else if (event.key === "Home") next = 0;
|
|
591
|
+
else if (event.key === "End") next = tabs.length - 1;
|
|
592
|
+
else return; // Buttons activate with native Enter/Space clicks.
|
|
593
|
+
event.preventDefault();
|
|
594
|
+
for (const [index, tab] of tabs.entries()) tab.tabIndex = index === next ? 0 : -1;
|
|
595
|
+
tabs[next]?.focus();
|
|
596
|
+
};
|
|
597
|
+
const modeChange = () => {
|
|
598
|
+
disposeResize();
|
|
599
|
+
if (isOpen()) {
|
|
600
|
+
seedWidth();
|
|
601
|
+
load();
|
|
602
|
+
} else unload();
|
|
603
|
+
disposeResize = attachResize();
|
|
604
|
+
update();
|
|
605
|
+
};
|
|
606
|
+
const closeSheet = () => {
|
|
607
|
+
const wasOpen = canvas.dataset.panelSheet === "open";
|
|
608
|
+
canvas.removeAttribute("data-panel-sheet");
|
|
609
|
+
if (wasOpen && isPhone()) {
|
|
610
|
+
unload();
|
|
611
|
+
update();
|
|
612
|
+
}
|
|
613
|
+
};
|
|
614
|
+
const pageShow = (event: PageTransitionEvent) => {
|
|
615
|
+
if (!event.persisted) return;
|
|
616
|
+
closeSheet();
|
|
617
|
+
// A restored page may hold answers changed elsewhere meanwhile: stop sharing and read them again.
|
|
618
|
+
if (!panel.providers.some((item) => item.consent)) return;
|
|
619
|
+
void revalidate();
|
|
620
|
+
};
|
|
621
|
+
const entryChange = (event: NavigationCurrentEntryChangeEvent) => {
|
|
622
|
+
if (event.navigationType === "push") closeSheet();
|
|
623
|
+
};
|
|
624
|
+
let dragWidth = width;
|
|
625
|
+
let dragStoredWidth = committedWidth;
|
|
626
|
+
let dragInlineWidth = "";
|
|
627
|
+
const restoreDragWidth = () => {
|
|
628
|
+
width = dragWidth;
|
|
629
|
+
committedWidth = dragStoredWidth;
|
|
630
|
+
if (dragInlineWidth) canvas.style.setProperty("--cloud-panel-width-set", dragInlineWidth);
|
|
631
|
+
else canvas.style.removeProperty("--cloud-panel-width-set");
|
|
632
|
+
separator?.setAttribute("aria-valuenow", String(Math.round(width * 100) / 100));
|
|
633
|
+
};
|
|
634
|
+
let endingDrag = false;
|
|
635
|
+
seedWidth();
|
|
636
|
+
const attachResize = () =>
|
|
637
|
+
separator && column.matches
|
|
638
|
+
? attachResizeSeparator(separator, {
|
|
639
|
+
min: PANEL_MIN_WIDTH_REM,
|
|
640
|
+
max: PANEL_MAX_WIDTH_REM,
|
|
641
|
+
value: () => width,
|
|
642
|
+
get unit() {
|
|
643
|
+
return rem();
|
|
644
|
+
},
|
|
645
|
+
step: 1,
|
|
646
|
+
largeStep: 4,
|
|
647
|
+
grow: "start",
|
|
648
|
+
collapseBelow: 16,
|
|
649
|
+
onResize: (value) => {
|
|
650
|
+
if (endingDrag) {
|
|
651
|
+
endingDrag = false;
|
|
652
|
+
restoreDragWidth();
|
|
653
|
+
} else setWidth(value);
|
|
654
|
+
},
|
|
655
|
+
onResizeEnd: (value) => {
|
|
656
|
+
endingDrag = false;
|
|
657
|
+
committedWidth = value;
|
|
658
|
+
writeCookie();
|
|
659
|
+
},
|
|
660
|
+
onCollapseIntent: (armed) => {
|
|
661
|
+
if (armed) slot.dataset.closing = "true";
|
|
662
|
+
else delete slot.dataset.closing;
|
|
663
|
+
},
|
|
664
|
+
onCollapse: () => {
|
|
665
|
+
endingDrag = false;
|
|
666
|
+
restoreDragWidth();
|
|
667
|
+
close();
|
|
668
|
+
},
|
|
669
|
+
onDragChange: (dragging) => {
|
|
670
|
+
if (dragging) {
|
|
671
|
+
dragWidth = width;
|
|
672
|
+
dragStoredWidth = committedWidth;
|
|
673
|
+
dragInlineWidth = canvas.style.getPropertyValue("--cloud-panel-width-set");
|
|
674
|
+
slot.dataset.resizing = "true";
|
|
675
|
+
} else {
|
|
676
|
+
delete slot.dataset.resizing;
|
|
677
|
+
endingDrag = true;
|
|
678
|
+
}
|
|
679
|
+
},
|
|
680
|
+
})
|
|
681
|
+
: () => {};
|
|
682
|
+
let disposeResize = attachResize();
|
|
683
|
+
collectContext();
|
|
684
|
+
window.addEventListener(PAGE_CONTEXT_CHANGED, collectContext);
|
|
685
|
+
window.addEventListener(COMMANDS_CHANGED, commandsChanged);
|
|
686
|
+
window.addEventListener("cloud.panel.consent", consentRequest);
|
|
687
|
+
window.addEventListener("message", message);
|
|
688
|
+
window.addEventListener("keydown", key);
|
|
689
|
+
slot.addEventListener("click", click);
|
|
690
|
+
phoneChange.addEventListener("change", modeChange);
|
|
691
|
+
column.addEventListener("change", modeChange);
|
|
692
|
+
window.addEventListener("pageshow", pageShow);
|
|
693
|
+
// Restored from the back/forward cache before this lazy module arrived: the server's answers may be outdated.
|
|
694
|
+
if (document.documentElement.hasAttribute("data-cloud-page-restored")) void revalidate();
|
|
695
|
+
window.addEventListener("popstate", closeSheet);
|
|
696
|
+
window.navigation?.addEventListener("currententrychange", entryChange);
|
|
697
|
+
window.addEventListener("resize", seedWidth);
|
|
698
|
+
// An SSR iframe can have posted its one-shot ready before this lazy module
|
|
699
|
+
// arrived. Its runtime also marks its own same-origin document, so read that
|
|
700
|
+
// once the listener is in place instead of loading the provider again.
|
|
701
|
+
if (isOpen() && frame) {
|
|
702
|
+
if (frame.contentDocument?.documentElement.dataset.cloudPanelReady === provider.id) markReady();
|
|
703
|
+
else loading();
|
|
704
|
+
} else if (isOpen()) load();
|
|
705
|
+
else unload();
|
|
706
|
+
update();
|
|
707
|
+
// Mounted only now, after update() wrote the state it starts from; a click before this has nothing to change.
|
|
708
|
+
let disposeMenu = () => {};
|
|
709
|
+
if (menuHost) {
|
|
710
|
+
menuHost.replaceChildren();
|
|
711
|
+
const initial: PanelState = {
|
|
712
|
+
provider: provider.id,
|
|
713
|
+
appName: provider.appName,
|
|
714
|
+
consent: provider.consent,
|
|
715
|
+
allowed: allowed(),
|
|
716
|
+
saving: saving.has(provider.id),
|
|
717
|
+
open: isOpen(),
|
|
718
|
+
};
|
|
719
|
+
disposeMenu = render(
|
|
720
|
+
() =>
|
|
721
|
+
createComponent(LocaleProvider, {
|
|
722
|
+
locale: lang,
|
|
723
|
+
get children() {
|
|
724
|
+
return createComponent(PanelMenu, { initial, locale: lang });
|
|
725
|
+
},
|
|
726
|
+
}),
|
|
727
|
+
menuHost,
|
|
728
|
+
);
|
|
729
|
+
}
|
|
730
|
+
const dispose = () => {
|
|
731
|
+
if (disposed) return;
|
|
732
|
+
disposed = true;
|
|
733
|
+
disposeMenu();
|
|
734
|
+
requests.abort();
|
|
735
|
+
++loadVersion;
|
|
736
|
+
clearContext();
|
|
737
|
+
clearTimer();
|
|
738
|
+
disposeResize();
|
|
739
|
+
removeFallbackFocus();
|
|
740
|
+
window.removeEventListener(PAGE_CONTEXT_CHANGED, collectContext);
|
|
741
|
+
window.removeEventListener(COMMANDS_CHANGED, commandsChanged);
|
|
742
|
+
window.removeEventListener("cloud.panel.consent", consentRequest);
|
|
743
|
+
window.removeEventListener("message", message);
|
|
744
|
+
window.removeEventListener("keydown", key);
|
|
745
|
+
slot.removeEventListener("click", click);
|
|
746
|
+
phoneChange.removeEventListener("change", modeChange);
|
|
747
|
+
column.removeEventListener("change", modeChange);
|
|
748
|
+
window.removeEventListener("pageshow", pageShow);
|
|
749
|
+
window.removeEventListener("popstate", closeSheet);
|
|
750
|
+
window.navigation?.removeEventListener("currententrychange", entryChange);
|
|
751
|
+
window.removeEventListener("resize", seedWidth);
|
|
752
|
+
installed = undefined;
|
|
753
|
+
};
|
|
754
|
+
installed = { dispose, request };
|
|
755
|
+
return dispose;
|
|
756
|
+
}
|