@k2b/cloud 0.33.0 → 0.35.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/package.json +3 -3
- package/src/_internal/app-presentation.ts +40 -11
- package/src/_internal/define-app.ts +1 -0
- package/src/_internal/registry.ts +10 -1
- package/src/_internal/widget-declarations.ts +16 -3
- package/src/_internal/widget-settings.ts +93 -0
- package/src/ai/capabilities.ts +130 -16
- package/src/ai/firecrawl-tools.ts +3 -30
- package/src/ai/resource-refs.ts +35 -3
- package/src/ai/system-prompt.ts +3 -2
- package/src/api/index.ts +2 -0
- package/src/api/mcp.ts +48 -17
- package/src/api/pwa.ts +7 -2
- package/src/api/resource-links.ts +43 -0
- package/src/api/widgets.ts +163 -82
- package/src/browser/CloudResourceSearch.tsx +6 -6
- package/src/browser/command-shortcuts.ts +2 -11
- package/src/browser/resource-picker.tsx +1 -0
- package/src/browser/resource-search.ts +7 -2
- package/src/browser/widgets.ts +23 -5
- package/src/capabilities/testing.ts +13 -1
- package/src/cli/capabilities.ts +61 -17
- package/src/cli/index.ts +2 -0
- package/src/cli/resource-links.ts +13 -0
- package/src/contracts/app.ts +7 -1
- package/src/contracts/capabilities.ts +25 -5
- package/src/contracts/registry.ts +3 -1
- package/src/contracts/resource-href.ts +7 -0
- package/src/contracts/widgets.ts +80 -6
- package/src/desktop/index.ts +3 -2
- package/src/desktop/solid.tsx +7 -5
- package/src/server/widget-request.ts +34 -5
- package/src/services/app-approval.ts +4 -2
- package/src/services/approvals/index.ts +40 -11
- package/src/services/approvals/list.ts +1 -0
- package/src/services/approvals/migrate.ts +10 -0
- package/src/services/network-security.ts +33 -9
- package/src/services/pdf/markdown.ts +11 -7
- package/src/services/pwa-devices.ts +68 -15
- package/src/shared/app-presentation.ts +49 -1
- package/src/shared/index.ts +1 -0
- package/src/shared/markdown/callout-sanitizer.ts +37 -0
- package/src/shared/markdown/index.ts +8 -2
- package/src/shared/network-address.ts +46 -45
- package/src/ssr/Layout.tsx +3 -15
- package/src/ssr/LayoutHelp.browser-harness.tsx +43 -0
- package/src/ssr/LayoutHelp.tsx +320 -183
- package/src/ssr/LayoutHelpTrigger.tsx +1 -3
- package/src/ssr/LayoutPreferences.tsx +15 -5
- package/src/ssr/MinimalLayoutPreferences.island.tsx +3 -9
- package/src/ssr/MobileProfileActions.tsx +3 -8
- package/src/ssr/ProfilePreferences.tsx +8 -10
- package/src/ssr/ShellRuntime.island.tsx +1 -5
- package/src/ssr/ThemeCycleButton.tsx +46 -0
- package/src/ssr/app-appearance.ts +6 -1
- package/src/ssr/layout-help-url.ts +39 -0
- package/src/ssr/platform-messages.ts +0 -8
- package/src/ssr/preference-controller.ts +53 -19
- package/src/ssr/profile-actions.ts +12 -16
- package/src/ssr/profile-preferences-messages.ts +4 -2
- package/src/ssr/rail-navigation.ts +3 -0
- package/src/ssr/shell-data.tsx +2 -2
- package/src/styles/effects.css +13 -3
- package/src/styles/global.css +47 -12
- package/src/styles/input.css +0 -8
- package/src/styles/tokens.css +0 -2
- package/src/styles/utilities-navigation.css +10 -13
- package/src/ssr/GlobalSearchHelpDialog.tsx +0 -104
package/src/ssr/LayoutHelp.tsx
CHANGED
|
@@ -1,29 +1,42 @@
|
|
|
1
|
-
import type { HelpDocumentManifest, HelpDocumentPayload
|
|
1
|
+
import type { HelpDocumentManifest, HelpDocumentPayload } from "@k2b/cloud/shared";
|
|
2
2
|
import { clipboard } from "@k2b/stdlib/solid";
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
import {
|
|
4
|
+
Button,
|
|
5
|
+
IconButton,
|
|
6
|
+
IconButtonLink,
|
|
7
|
+
Kbd,
|
|
8
|
+
MarkdownView,
|
|
9
|
+
NoticeCard,
|
|
10
|
+
Placeholder,
|
|
11
|
+
prefersReducedMotion,
|
|
12
|
+
prompts,
|
|
13
|
+
ScrollArea,
|
|
14
|
+
TextInput,
|
|
15
|
+
useLocale,
|
|
16
|
+
} from "@k2b/ui";
|
|
17
|
+
import { createEffect, createMemo, createSignal, For, type JSX, onCleanup, onMount, Show } from "solid-js";
|
|
18
|
+
import { COMMANDS_CHANGED, contextCommandsWithShortcuts } from "../browser/command-shortcuts";
|
|
6
19
|
import { appAccentStyle } from "./app-appearance";
|
|
7
|
-
import { type GlobalSearchHelpApp, openGlobalSearchHelpDialog } from "./GlobalSearchHelpDialog";
|
|
8
20
|
import { helpMessages } from "./help-messages";
|
|
9
21
|
import { formatHelpBundleMarkdown, formatHelpDocumentMarkdown } from "./layout-help-markdown";
|
|
10
22
|
import { adjacentHelpDocuments, focusHelpArticleHeading, resetHelpArticleScroll } from "./layout-help-navigation";
|
|
11
23
|
import { HELP_TOPICS_EVENT, type HelpTopic, registry } from "./layout-help-registry";
|
|
12
24
|
import { searchHelpTopics } from "./layout-help-search";
|
|
13
|
-
import { layoutHelpTopicHref } from "./layout-help-url";
|
|
25
|
+
import { helpLinkTarget, layoutHelpTopicHref } from "./layout-help-url";
|
|
26
|
+
import { readShellData, useShellData } from "./shell-data";
|
|
14
27
|
|
|
15
28
|
export type LayoutHelpPageProps = {
|
|
16
29
|
documents: readonly HelpDocumentManifest[];
|
|
17
30
|
initialTopic?: string;
|
|
18
31
|
includeShortcuts?: boolean;
|
|
19
32
|
accent?: string;
|
|
20
|
-
/** Render inside an app-owned pane instead of occupying a
|
|
33
|
+
/** Render inside an app-owned pane instead of occupying a full page. */
|
|
21
34
|
embedded?: boolean;
|
|
22
|
-
/** Canonical
|
|
35
|
+
/** Canonical full-page Help route owned by the current app. */
|
|
23
36
|
pageBase?: string;
|
|
24
37
|
};
|
|
25
38
|
|
|
26
|
-
type HelpView = "hub" | "
|
|
39
|
+
type HelpView = "hub" | "article";
|
|
27
40
|
type HelpSession = {
|
|
28
41
|
view: HelpView;
|
|
29
42
|
query: string;
|
|
@@ -52,7 +65,7 @@ const mergeTopics = (registered: readonly HelpTopic[], documents: readonly HelpD
|
|
|
52
65
|
return [...topics.values()].sort((a, b) => (a.order ?? 100) - (b.order ?? 100) || a.title.localeCompare(b.title));
|
|
53
66
|
};
|
|
54
67
|
|
|
55
|
-
const Shortcuts = (props: {
|
|
68
|
+
const Shortcuts = (props: { focusSearch: () => void }) => {
|
|
56
69
|
const locale = useLocale();
|
|
57
70
|
const t = () => helpMessages.resolve([locale()]).t;
|
|
58
71
|
const [commands, setCommands] = createSignal(contextCommandsWithShortcuts());
|
|
@@ -65,7 +78,7 @@ const Shortcuts = (props: { openSearchHelp: () => void }) => {
|
|
|
65
78
|
return (
|
|
66
79
|
<div class="flex flex-col gap-3">
|
|
67
80
|
<p class="text-sm leading-relaxed text-dimmed">{t().shortcutsIntro}</p>
|
|
68
|
-
<Button size="sm" variant="secondary" class="self-start" onClick={props.
|
|
81
|
+
<Button size="sm" variant="secondary" class="self-start" onClick={props.focusSearch}>
|
|
69
82
|
<i class="ti ti-search" /> {t().searchHelp}
|
|
70
83
|
</Button>
|
|
71
84
|
<div class="flex flex-col gap-2">
|
|
@@ -78,9 +91,7 @@ const Shortcuts = (props: { openSearchHelp: () => void }) => {
|
|
|
78
91
|
<p class="mt-0.5 text-xs text-dimmed">{entry.description}</p>
|
|
79
92
|
</Show>
|
|
80
93
|
</div>
|
|
81
|
-
<
|
|
82
|
-
{shortcutLabel(entry.shortcut!)}
|
|
83
|
-
</kbd>
|
|
94
|
+
<Kbd keys={entry.shortcut!} />
|
|
84
95
|
</div>
|
|
85
96
|
)}
|
|
86
97
|
</For>
|
|
@@ -93,7 +104,6 @@ const HelpShell = (props: {
|
|
|
93
104
|
session: HelpSession;
|
|
94
105
|
close: () => void;
|
|
95
106
|
pageBase?: string;
|
|
96
|
-
searchHelpApps: GlobalSearchHelpApp[];
|
|
97
107
|
documents?: readonly HelpDocumentManifest[];
|
|
98
108
|
includeShortcuts?: boolean;
|
|
99
109
|
accent?: string;
|
|
@@ -102,6 +112,8 @@ const HelpShell = (props: {
|
|
|
102
112
|
}) => {
|
|
103
113
|
const locale = useLocale();
|
|
104
114
|
const t = () => helpMessages.resolve([locale()]).t;
|
|
115
|
+
// Links to this app's Help, by its own route or Core's `/help/apps/<id>`, open in place.
|
|
116
|
+
const appId = useShellData()?.appId;
|
|
105
117
|
const [externalTopics, setExternalTopics] = createSignal(mergeTopics(sortedTopics(), props.documents));
|
|
106
118
|
const [view, setView] = createSignal<HelpView>(props.session.view);
|
|
107
119
|
const [query, setQuery] = createSignal(props.session.query);
|
|
@@ -122,20 +134,45 @@ const HelpShell = (props: {
|
|
|
122
134
|
let scrollArea: HTMLDivElement | undefined;
|
|
123
135
|
let articleContent: HTMLDivElement | undefined;
|
|
124
136
|
let articleHeading: HTMLHeadingElement | undefined;
|
|
137
|
+
let searchInput: HTMLInputElement | undefined;
|
|
138
|
+
let resultsRegion: HTMLDivElement | undefined;
|
|
139
|
+
const [resultsMinHeight, setResultsMinHeight] = createSignal<string>();
|
|
125
140
|
let pendingArticleFocus = false;
|
|
141
|
+
let pendingNavigation = false;
|
|
142
|
+
/** Where the page's own address points in this Help; a query string, such as a campaign tag, does not matter here. */
|
|
143
|
+
const currentHelpTarget = () =>
|
|
144
|
+
helpLinkTarget(`${location.pathname}${location.hash}`, location.href, { appId, pageBase: props.pageBase });
|
|
145
|
+
let pendingHash = props.syncPageUrl && typeof location !== "undefined" ? (currentHelpTarget()?.hash ?? "") : "";
|
|
146
|
+
/**
|
|
147
|
+
* The address Help shows now. The phone menu and dialogs with a history entry put a second entry on the same address
|
|
148
|
+
* and take it away again; that is not Help navigation and must not move the article or the search.
|
|
149
|
+
*/
|
|
150
|
+
const pageAddress = () => `${location.pathname}${location.hash}`;
|
|
151
|
+
let shownAddress = props.syncPageUrl && typeof location !== "undefined" ? pageAddress() : "";
|
|
126
152
|
let requestVersion = 0;
|
|
127
153
|
let searchVersion = 0;
|
|
128
154
|
let copyAllController: AbortController | undefined;
|
|
129
155
|
|
|
130
|
-
const
|
|
156
|
+
const articleScroller = () => {
|
|
157
|
+
if (props.surface !== "page") return scrollArea;
|
|
158
|
+
for (let element = root?.parentElement; element; element = element.parentElement) {
|
|
159
|
+
if (/(auto|scroll)/.test(getComputedStyle(element).overflowY)) return element;
|
|
160
|
+
}
|
|
161
|
+
return document.scrollingElement;
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
const restoreArticleScroll = () => {
|
|
165
|
+
if (props.surface !== "modal" || pendingNavigation || pendingHash) return;
|
|
131
166
|
requestAnimationFrame(() => {
|
|
132
167
|
if (scrollArea && view() === "article") scrollArea.scrollTop = props.session.articleScrollTop;
|
|
133
168
|
});
|
|
169
|
+
};
|
|
134
170
|
|
|
135
|
-
const syncActiveSection = () => {
|
|
171
|
+
const syncActiveSection = (scroller = articleScroller()) => {
|
|
136
172
|
const sections = articleSections();
|
|
137
|
-
if (!
|
|
138
|
-
const
|
|
173
|
+
if (!scroller || sections.length === 0) return;
|
|
174
|
+
const top = scroller === document.scrollingElement ? 0 : scroller.getBoundingClientRect().top;
|
|
175
|
+
const readingLine = top + Math.min(160, scroller.clientHeight * 0.22);
|
|
139
176
|
let active = sections[0];
|
|
140
177
|
for (const section of sections) {
|
|
141
178
|
if (section.element.getBoundingClientRect().top <= readingLine) active = section;
|
|
@@ -144,6 +181,16 @@ const HelpShell = (props: {
|
|
|
144
181
|
setActiveSectionId(active?.id ?? null);
|
|
145
182
|
};
|
|
146
183
|
|
|
184
|
+
const scrollToHash = (hash: string) => {
|
|
185
|
+
const heading = Array.from(
|
|
186
|
+
articleContent?.querySelectorAll<HTMLHeadingElement>("h1[id], h2[id], h3[id], h4[id], h5[id], h6[id]") ?? [],
|
|
187
|
+
).find((element) => element.id === hash);
|
|
188
|
+
if (!heading) return false;
|
|
189
|
+
setActiveSectionId(hash);
|
|
190
|
+
heading.scrollIntoView({ block: "start", behavior: prefersReducedMotion() ? "auto" : "smooth" });
|
|
191
|
+
return true;
|
|
192
|
+
};
|
|
193
|
+
|
|
147
194
|
const collectArticleSections = () => {
|
|
148
195
|
const sections = Array.from(articleContent?.querySelectorAll<HTMLHeadingElement>("h2[id]") ?? []).map((element) => ({
|
|
149
196
|
element,
|
|
@@ -153,15 +200,25 @@ const HelpShell = (props: {
|
|
|
153
200
|
}));
|
|
154
201
|
setArticleSections(sections);
|
|
155
202
|
setActiveSectionId(sections[0]?.id ?? null);
|
|
156
|
-
|
|
203
|
+
const openedSection = pendingHash ? scrollToHash(pendingHash) : false;
|
|
204
|
+
pendingHash = "";
|
|
205
|
+
pendingNavigation = false;
|
|
206
|
+
if (!openedSection) syncActiveSection();
|
|
157
207
|
};
|
|
158
208
|
|
|
159
209
|
const openArticleSection = (section: HelpSection) => {
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
210
|
+
scrollToHash(section.id);
|
|
211
|
+
if (props.syncPageUrl) {
|
|
212
|
+
const url = new URL(location.href);
|
|
213
|
+
url.hash = encodeURIComponent(section.id);
|
|
214
|
+
history.replaceState(history.state, "", url);
|
|
215
|
+
shownAddress = pageAddress();
|
|
216
|
+
}
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
const focusSearch = () => {
|
|
220
|
+
showHub();
|
|
221
|
+
requestAnimationFrame(() => searchInput?.focus({ preventScroll: true }));
|
|
165
222
|
};
|
|
166
223
|
|
|
167
224
|
const shortcutsTopic = createMemo<HelpTopic>(() => ({
|
|
@@ -171,7 +228,7 @@ const HelpShell = (props: {
|
|
|
171
228
|
description: t().shortcutsDescription,
|
|
172
229
|
order: 0,
|
|
173
230
|
kind: "content",
|
|
174
|
-
children: <Shortcuts
|
|
231
|
+
children: <Shortcuts focusSearch={focusSearch} />,
|
|
175
232
|
}));
|
|
176
233
|
const topics = createMemo(() => (props.includeShortcuts === false ? externalTopics() : [shortcutsTopic(), ...externalTopics()]));
|
|
177
234
|
const documentTopics = createMemo(() =>
|
|
@@ -210,8 +267,10 @@ const HelpShell = (props: {
|
|
|
210
267
|
headers: { Accept: "application/json" },
|
|
211
268
|
});
|
|
212
269
|
if (!response.ok) throw new Error(t().searchFailed);
|
|
213
|
-
const payload =
|
|
214
|
-
return
|
|
270
|
+
const payload: unknown = await response.json();
|
|
271
|
+
return payload && typeof payload === "object" && "ids" in payload && Array.isArray(payload.ids)
|
|
272
|
+
? payload.ids.filter((id): id is string => typeof id === "string")
|
|
273
|
+
: [];
|
|
215
274
|
}),
|
|
216
275
|
)
|
|
217
276
|
.then((groups) => {
|
|
@@ -240,7 +299,13 @@ const HelpShell = (props: {
|
|
|
240
299
|
setActiveSectionId(null);
|
|
241
300
|
return;
|
|
242
301
|
}
|
|
243
|
-
const frame = requestAnimationFrame(
|
|
302
|
+
const frame = requestAnimationFrame(() => {
|
|
303
|
+
if (pendingNavigation && !pendingHash) {
|
|
304
|
+
const scroller = articleScroller();
|
|
305
|
+
if (scroller) scroller.scrollTop = 0;
|
|
306
|
+
}
|
|
307
|
+
collectArticleSections();
|
|
308
|
+
});
|
|
244
309
|
onCleanup(() => cancelAnimationFrame(frame));
|
|
245
310
|
});
|
|
246
311
|
|
|
@@ -272,6 +337,35 @@ const HelpShell = (props: {
|
|
|
272
337
|
update();
|
|
273
338
|
onCleanup(() => window.removeEventListener(HELP_TOPICS_EVENT, update));
|
|
274
339
|
restoreArticleScroll();
|
|
340
|
+
let scrollFrame: number | undefined;
|
|
341
|
+
const onScroll = (event: Event) => {
|
|
342
|
+
const scroller = event.target === document ? document.scrollingElement : event.target;
|
|
343
|
+
if (!(scroller instanceof Element) || !articleContent || !scroller.contains(articleContent) || view() !== "article") return;
|
|
344
|
+
if (props.surface === "modal" && scroller === scrollArea) props.session.articleScrollTop = scroller.scrollTop;
|
|
345
|
+
if (scrollFrame !== undefined) return;
|
|
346
|
+
scrollFrame = requestAnimationFrame(() => {
|
|
347
|
+
scrollFrame = undefined;
|
|
348
|
+
syncActiveSection(scroller);
|
|
349
|
+
});
|
|
350
|
+
};
|
|
351
|
+
document.addEventListener("scroll", onScroll, { capture: true, passive: true });
|
|
352
|
+
const onPopState = () => {
|
|
353
|
+
const address = pageAddress();
|
|
354
|
+
if (address === shownAddress) return;
|
|
355
|
+
const target = currentHelpTarget();
|
|
356
|
+
if (target?.kind !== "topic") return;
|
|
357
|
+
shownAddress = address;
|
|
358
|
+
const id = target.topicId;
|
|
359
|
+
if (id !== null && topics().some((topic) => topic.id === id)) openTopic(id, target.hash, false);
|
|
360
|
+
// The hub keeps its search, as the reader's own Back button does. An unknown topic shows the hub, as the server does.
|
|
361
|
+
else setView("hub");
|
|
362
|
+
};
|
|
363
|
+
if (props.syncPageUrl) window.addEventListener("popstate", onPopState);
|
|
364
|
+
onCleanup(() => {
|
|
365
|
+
document.removeEventListener("scroll", onScroll, true);
|
|
366
|
+
window.removeEventListener("popstate", onPopState);
|
|
367
|
+
if (scrollFrame !== undefined) cancelAnimationFrame(scrollFrame);
|
|
368
|
+
});
|
|
275
369
|
});
|
|
276
370
|
onCleanup(() => copyAllController?.abort());
|
|
277
371
|
|
|
@@ -282,18 +376,30 @@ const HelpShell = (props: {
|
|
|
282
376
|
|
|
283
377
|
const response = await fetch(topic.url, { signal, headers: { Accept: "application/json" } });
|
|
284
378
|
if (!response.ok) throw new Error(t().requestFailed);
|
|
285
|
-
const value =
|
|
379
|
+
const value: unknown = await response.json();
|
|
286
380
|
if (
|
|
381
|
+
!value ||
|
|
382
|
+
typeof value !== "object" ||
|
|
383
|
+
!("id" in value) ||
|
|
287
384
|
value.id !== topic.id ||
|
|
288
|
-
(
|
|
289
|
-
(
|
|
385
|
+
!("html" in value) ||
|
|
386
|
+
!("markdown" in value) ||
|
|
387
|
+
!("title" in value) ||
|
|
388
|
+
("locale" in value && value.locale !== undefined && typeof value.locale !== "string") ||
|
|
389
|
+
(topic.locale !== undefined && "locale" in value && value.locale !== undefined && value.locale !== topic.locale) ||
|
|
290
390
|
typeof value.html !== "string" ||
|
|
291
391
|
typeof value.markdown !== "string" ||
|
|
292
392
|
typeof value.title !== "string"
|
|
293
393
|
) {
|
|
294
394
|
throw new Error(t().invalidDocument);
|
|
295
395
|
}
|
|
296
|
-
const document: HelpDocumentPayload = {
|
|
396
|
+
const document: HelpDocumentPayload = {
|
|
397
|
+
id: topic.id,
|
|
398
|
+
title: value.title,
|
|
399
|
+
html: value.html,
|
|
400
|
+
markdown: value.markdown,
|
|
401
|
+
locale: "locale" in value && typeof value.locale === "string" ? value.locale : (topic.locale ?? "en"),
|
|
402
|
+
};
|
|
297
403
|
props.session.articleCache.set(cacheKey, document);
|
|
298
404
|
return document;
|
|
299
405
|
};
|
|
@@ -369,44 +475,79 @@ const HelpShell = (props: {
|
|
|
369
475
|
}
|
|
370
476
|
};
|
|
371
477
|
|
|
372
|
-
const
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
setView("article");
|
|
377
|
-
if (props.syncPageUrl) {
|
|
378
|
-
if (props.pageBase) history.replaceState(history.state, "", layoutHelpTopicHref(props.pageBase, id));
|
|
478
|
+
const pushHelpUrl = (id: string | null, hash = "") => {
|
|
479
|
+
if (props.syncPageUrl && props.pageBase) {
|
|
480
|
+
history.pushState(history.state, "", layoutHelpTopicHref(props.pageBase, id) + (hash ? `#${encodeURIComponent(hash)}` : ""));
|
|
481
|
+
shownAddress = pageAddress();
|
|
379
482
|
}
|
|
483
|
+
};
|
|
484
|
+
const openTopic = (id: string, hash = "", push = true) => {
|
|
485
|
+
if (push) pushHelpUrl(id, hash);
|
|
380
486
|
try {
|
|
381
487
|
localStorage.setItem(LAST_TOPIC_KEY, id);
|
|
382
488
|
} catch {
|
|
383
489
|
/* Help does not depend on storage. */
|
|
384
490
|
}
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
if (props.pageBase) history.replaceState(history.state, "", layoutHelpTopicHref(props.pageBase, null));
|
|
491
|
+
// The open article does not load again: go to its section, or to its top.
|
|
492
|
+
if (view() === "article" && activeId() === id && payload()) {
|
|
493
|
+
if (!hash || !scrollToHash(hash)) resetHelpArticleScroll(props.session, articleScroller() ?? undefined);
|
|
494
|
+
return;
|
|
390
495
|
}
|
|
496
|
+
resetHelpArticleScroll(props.session, articleScroller() ?? undefined);
|
|
497
|
+
pendingArticleFocus = true;
|
|
498
|
+
pendingNavigation = true;
|
|
499
|
+
pendingHash = hash;
|
|
500
|
+
setActiveId(id);
|
|
501
|
+
setView("article");
|
|
391
502
|
};
|
|
392
|
-
const showHub = () => {
|
|
503
|
+
const showHub = (push = true) => {
|
|
393
504
|
setQuery("");
|
|
505
|
+
setResultsMinHeight(undefined);
|
|
394
506
|
setRemoteIds(null);
|
|
395
507
|
setView("hub");
|
|
508
|
+
if (push) pushHelpUrl(null);
|
|
396
509
|
};
|
|
510
|
+
const goBack = () => {
|
|
511
|
+
setView("hub");
|
|
512
|
+
pushHelpUrl(null);
|
|
513
|
+
};
|
|
514
|
+
|
|
515
|
+
const followHelpLink = (event: MouseEvent) => {
|
|
516
|
+
if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
|
|
517
|
+
if (!(event.target instanceof Element)) return;
|
|
518
|
+
const anchor = event.target.closest("a");
|
|
519
|
+
if (!anchor || !articleContent?.contains(anchor) || (anchor.target && anchor.target !== "_self") || anchor.hasAttribute("download"))
|
|
520
|
+
return;
|
|
521
|
+
const target = helpLinkTarget(anchor.getAttribute("href") ?? "", location.href, { appId, pageBase: props.pageBase });
|
|
522
|
+
if (!target) return;
|
|
523
|
+
if (target.kind === "section") {
|
|
524
|
+
if (!scrollToHash(target.hash)) return;
|
|
525
|
+
event.preventDefault();
|
|
526
|
+
if (props.syncPageUrl) {
|
|
527
|
+
const url = new URL(location.href);
|
|
528
|
+
url.hash = encodeURIComponent(target.hash);
|
|
529
|
+
history.replaceState(history.state, "", url);
|
|
530
|
+
shownAddress = pageAddress();
|
|
531
|
+
}
|
|
532
|
+
} else if (target.topicId === null) {
|
|
533
|
+
event.preventDefault();
|
|
534
|
+
showHub();
|
|
535
|
+
} else if (topics().some((topic) => topic.id === target.topicId)) {
|
|
536
|
+
event.preventDefault();
|
|
537
|
+
openTopic(target.topicId, target.hash);
|
|
538
|
+
}
|
|
539
|
+
};
|
|
540
|
+
|
|
397
541
|
const modalTitle = createMemo(() => {
|
|
398
542
|
if (view() === "article") return activeTopic()?.title ?? t().help;
|
|
399
|
-
if (view() === "search") return t().searchHelp;
|
|
400
543
|
return t().help;
|
|
401
544
|
});
|
|
402
545
|
const modalDescription = createMemo(() => {
|
|
403
546
|
if (view() === "article") return activeTopic()?.description ?? t().description;
|
|
404
|
-
if (view() === "search") return t().searchDescription;
|
|
405
547
|
return t().description;
|
|
406
548
|
});
|
|
407
549
|
const modalIcon = createMemo(() => {
|
|
408
550
|
if (view() === "article") return iconClass(activeTopic()?.icon);
|
|
409
|
-
if (view() === "search") return "ti ti-search";
|
|
410
551
|
return "ti ti-help";
|
|
411
552
|
});
|
|
412
553
|
|
|
@@ -472,14 +613,23 @@ const HelpShell = (props: {
|
|
|
472
613
|
</Show>
|
|
473
614
|
);
|
|
474
615
|
|
|
616
|
+
const ReaderContent = (content: { children: JSX.Element }) =>
|
|
617
|
+
props.surface === "page" ? (
|
|
618
|
+
<div class="p-4 sm:p-5">{content.children}</div>
|
|
619
|
+
) : (
|
|
620
|
+
<ScrollArea ref={scrollArea} class="min-h-0 flex-1 p-4 sm:p-5">
|
|
621
|
+
{content.children}
|
|
622
|
+
</ScrollArea>
|
|
623
|
+
);
|
|
624
|
+
|
|
475
625
|
return (
|
|
476
626
|
<div
|
|
477
627
|
ref={root}
|
|
478
|
-
class={`app-accent-scope flex min-h-0 flex-col bg-[var(--ui-surface-raised)] ${
|
|
628
|
+
class={`app-accent-scope flex min-h-0 flex-col ${props.surface === "page" ? "" : "bg-[var(--ui-surface-raised)]"} ${
|
|
479
629
|
props.surface === "modal"
|
|
480
630
|
? "h-[min(48rem,var(--ui-dialog-available-height))] overflow-hidden panel-dialog-shell [box-shadow:var(--ui-shadow-float)]"
|
|
481
631
|
: props.surface === "page"
|
|
482
|
-
? "
|
|
632
|
+
? ""
|
|
483
633
|
: "h-full"
|
|
484
634
|
}`}
|
|
485
635
|
style={appAccentStyle(props.accent)}
|
|
@@ -487,12 +637,7 @@ const HelpShell = (props: {
|
|
|
487
637
|
<Show when={props.surface === "modal"}>
|
|
488
638
|
<header class="flex min-h-16 shrink-0 items-center gap-2 px-5 py-3">
|
|
489
639
|
<Show when={view() !== "hub"}>
|
|
490
|
-
<IconButton
|
|
491
|
-
class="shrink-0"
|
|
492
|
-
label={view() === "article" ? t().backToTopics : t().backToHelp}
|
|
493
|
-
title={t().back}
|
|
494
|
-
onClick={() => (view() === "article" ? goBack() : showHub())}
|
|
495
|
-
>
|
|
640
|
+
<IconButton class="shrink-0" label={t().backToTopics} title={t().back} onClick={goBack}>
|
|
496
641
|
<i class="ti ti-arrow-left" />
|
|
497
642
|
</IconButton>
|
|
498
643
|
</Show>
|
|
@@ -565,16 +710,7 @@ const HelpShell = (props: {
|
|
|
565
710
|
</header>
|
|
566
711
|
</Show>
|
|
567
712
|
|
|
568
|
-
<
|
|
569
|
-
ref={scrollArea}
|
|
570
|
-
class="min-h-0 flex-1 p-4 sm:p-5"
|
|
571
|
-
onScroll={(event) => {
|
|
572
|
-
if (view() === "article") {
|
|
573
|
-
props.session.articleScrollTop = event.currentTarget.scrollTop;
|
|
574
|
-
syncActiveSection();
|
|
575
|
-
}
|
|
576
|
-
}}
|
|
577
|
-
>
|
|
713
|
+
<ReaderContent>
|
|
578
714
|
<Show when={view() === "hub"}>
|
|
579
715
|
<div class="mx-auto flex max-w-3xl flex-col gap-6">
|
|
580
716
|
<div class="flex flex-col items-start justify-between gap-3 sm:flex-row">
|
|
@@ -596,70 +732,70 @@ const HelpShell = (props: {
|
|
|
596
732
|
</p>
|
|
597
733
|
)}
|
|
598
734
|
</Show>
|
|
599
|
-
<
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
<
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
735
|
+
<TextInput
|
|
736
|
+
ref={searchInput}
|
|
737
|
+
type="search"
|
|
738
|
+
icon="ti ti-search"
|
|
739
|
+
aria-label={t().searchHelp}
|
|
740
|
+
data-help-search=""
|
|
741
|
+
value={query()}
|
|
742
|
+
placeholder={t().searchPlaceholder}
|
|
743
|
+
onValueChange={(value) => {
|
|
744
|
+
// Results replace the start lists below the field; the area keeps its height so nothing moves.
|
|
745
|
+
if (!query().trim() && value.trim()) setResultsMinHeight(`${resultsRegion?.offsetHeight ?? 0}px`);
|
|
746
|
+
if (!value.trim()) setResultsMinHeight(undefined);
|
|
747
|
+
setQuery(value);
|
|
748
|
+
}}
|
|
749
|
+
/>
|
|
750
|
+
<div ref={resultsRegion} data-help-results="" class="flex flex-col gap-4" style={{ "min-height": resultsMinHeight() }}>
|
|
751
|
+
<Show
|
|
752
|
+
when={normalizedQuery()}
|
|
753
|
+
fallback={
|
|
754
|
+
<>
|
|
755
|
+
<section aria-labelledby="help-start-title">
|
|
756
|
+
<h4 id="help-start-title" class="mb-2 text-xs font-semibold uppercase tracking-wide text-dimmed">
|
|
757
|
+
{t().startHere}
|
|
758
|
+
</h4>
|
|
759
|
+
<TopicList items={topics().slice(0, 5)} />
|
|
760
|
+
</section>
|
|
761
|
+
<Show when={topics().length > 5}>
|
|
762
|
+
<section aria-labelledby="help-all-title">
|
|
763
|
+
<h4 id="help-all-title" class="mb-2 text-xs font-semibold uppercase tracking-wide text-dimmed">
|
|
764
|
+
{t().allTopics}
|
|
765
|
+
</h4>
|
|
766
|
+
<TopicList items={topics().slice(5)} />
|
|
767
|
+
</section>
|
|
768
|
+
</Show>
|
|
769
|
+
</>
|
|
770
|
+
}
|
|
771
|
+
>
|
|
772
|
+
{/* The count takes the place of the "Start here" title, so the first row stays where it was. */}
|
|
773
|
+
<section>
|
|
774
|
+
<p class="mb-2 flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wide text-dimmed" aria-live="polite">
|
|
775
|
+
{t().results({ count: results().length })}
|
|
776
|
+
<Show when={searching()}>
|
|
777
|
+
<i class="ti ti-loader-2 animate-spin" aria-hidden="true" />
|
|
778
|
+
</Show>
|
|
779
|
+
</p>
|
|
780
|
+
<Show
|
|
781
|
+
when={results().length > 0}
|
|
782
|
+
fallback={
|
|
783
|
+
<Show when={!searching()}>
|
|
784
|
+
<Placeholder icon="ti ti-search-off" title={t().noResults} />
|
|
785
|
+
</Show>
|
|
786
|
+
}
|
|
787
|
+
>
|
|
788
|
+
<TopicList items={results()} />
|
|
789
|
+
</Show>
|
|
790
|
+
</section>
|
|
635
791
|
</Show>
|
|
636
|
-
<label class="field flex h-11 flex-1 items-center gap-2 px-3">
|
|
637
|
-
<i class="ti ti-search text-dimmed" />
|
|
638
|
-
<input
|
|
639
|
-
autofocus
|
|
640
|
-
class="min-w-0 flex-1 bg-transparent text-sm outline-none"
|
|
641
|
-
value={query()}
|
|
642
|
-
placeholder={t().searchPlaceholder}
|
|
643
|
-
onInput={(event) => setQuery(event.currentTarget.value)}
|
|
644
|
-
/>
|
|
645
|
-
</label>
|
|
646
792
|
</div>
|
|
647
|
-
<p class="flex items-center gap-1.5 text-xs text-dimmed" aria-live="polite">
|
|
648
|
-
<Show when={searching()}>
|
|
649
|
-
<i class="ti ti-loader-2 animate-spin" aria-hidden="true" />
|
|
650
|
-
</Show>
|
|
651
|
-
{t().results({ count: results().length })}
|
|
652
|
-
</p>
|
|
653
|
-
<TopicList items={results()} />
|
|
654
|
-
<Show when={results().length === 0 && !searching()}>
|
|
655
|
-
<Placeholder icon="ti ti-search-off" title={t().noResults} />
|
|
656
|
-
</Show>
|
|
657
793
|
</div>
|
|
658
794
|
</Show>
|
|
659
795
|
|
|
660
796
|
<Show when={view() === "article" && activeTopic()}>
|
|
661
797
|
{(topic) => (
|
|
662
|
-
<article class="mx-auto flex min-h-full w-full max-w-7xl flex-col">
|
|
798
|
+
<article class="help-article mx-auto flex min-h-full w-full max-w-7xl flex-col">
|
|
663
799
|
<Show when={props.surface !== "modal"}>
|
|
664
800
|
<button
|
|
665
801
|
type="button"
|
|
@@ -695,54 +831,63 @@ const HelpShell = (props: {
|
|
|
695
831
|
</Show>
|
|
696
832
|
<Show when={topic().kind === "content"}>{legacyTopicContent(topic())}</Show>
|
|
697
833
|
<Show when={topic().kind === "document"}>
|
|
698
|
-
<
|
|
699
|
-
<
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
</button>
|
|
731
|
-
</li>
|
|
732
|
-
)}
|
|
733
|
-
</For>
|
|
734
|
-
</ol>
|
|
735
|
-
</nav>
|
|
736
|
-
</Show>
|
|
737
|
-
<div class="help-article-copy">
|
|
738
|
-
<div ref={articleContent}>
|
|
739
|
-
<MarkdownView trustedHtml={document().html} class="help-document" />
|
|
740
|
-
</div>
|
|
741
|
-
<TopicNavigation />
|
|
834
|
+
<div class="help-article-layout">
|
|
835
|
+
<nav
|
|
836
|
+
class="help-article-toc"
|
|
837
|
+
aria-label={t().onThisPage}
|
|
838
|
+
aria-hidden={articleSections().length === 0 ? "true" : undefined}
|
|
839
|
+
data-empty={articleSections().length === 0 ? "" : undefined}
|
|
840
|
+
>
|
|
841
|
+
<p class="help-article-toc-label">{t().onThisPage}</p>
|
|
842
|
+
<ol>
|
|
843
|
+
<For each={articleSections()}>
|
|
844
|
+
{(section) => (
|
|
845
|
+
<li>
|
|
846
|
+
<button
|
|
847
|
+
type="button"
|
|
848
|
+
aria-current={activeSectionId() === section.id ? "location" : undefined}
|
|
849
|
+
title={section.title}
|
|
850
|
+
onClick={() => openArticleSection(section)}
|
|
851
|
+
>
|
|
852
|
+
<span class="help-article-toc-icon" aria-hidden="true">
|
|
853
|
+
<i class={section.icon} />
|
|
854
|
+
</span>
|
|
855
|
+
<span class="help-article-toc-title">{section.title}</span>
|
|
856
|
+
</button>
|
|
857
|
+
</li>
|
|
858
|
+
)}
|
|
859
|
+
</For>
|
|
860
|
+
</ol>
|
|
861
|
+
</nav>
|
|
862
|
+
<div class="help-article-copy">
|
|
863
|
+
<Show when={loading()}>
|
|
864
|
+
<div class="min-h-[24rem]">
|
|
865
|
+
<Placeholder state="loading" variant="panel" title={t().loading} />
|
|
742
866
|
</div>
|
|
867
|
+
</Show>
|
|
868
|
+
<Show when={loadError()}>
|
|
869
|
+
{(message) => (
|
|
870
|
+
<NoticeCard tone="danger" title={t().loadFailed} detail={message()}>
|
|
871
|
+
<Button size="sm" variant="secondary" onClick={() => setLoadAttempt((value) => value + 1)}>
|
|
872
|
+
{t().tryAgain}
|
|
873
|
+
</Button>
|
|
874
|
+
</NoticeCard>
|
|
875
|
+
)}
|
|
876
|
+
</Show>
|
|
877
|
+
<div
|
|
878
|
+
ref={(element) => {
|
|
879
|
+
articleContent = element;
|
|
880
|
+
element.addEventListener("click", followHelpLink);
|
|
881
|
+
onCleanup(() => element.removeEventListener("click", followHelpLink));
|
|
882
|
+
}}
|
|
883
|
+
>
|
|
884
|
+
<Show when={payload()}>{(document) => <MarkdownView trustedHtml={document().html} class="help-document" />}</Show>
|
|
743
885
|
</div>
|
|
744
|
-
|
|
745
|
-
|
|
886
|
+
<Show when={payload()}>
|
|
887
|
+
<TopicNavigation />
|
|
888
|
+
</Show>
|
|
889
|
+
</div>
|
|
890
|
+
</div>
|
|
746
891
|
</Show>
|
|
747
892
|
</article>
|
|
748
893
|
)}
|
|
@@ -750,7 +895,7 @@ const HelpShell = (props: {
|
|
|
750
895
|
<span class="sr-only" aria-live="polite">
|
|
751
896
|
{articleClipboard.wasCopied() ? t().articleCopiedAnnouncement : allHelpClipboard.wasCopied() ? t().allCopiedAnnouncement : ""}
|
|
752
897
|
</span>
|
|
753
|
-
</
|
|
898
|
+
</ReaderContent>
|
|
754
899
|
</div>
|
|
755
900
|
);
|
|
756
901
|
};
|
|
@@ -784,7 +929,6 @@ export function LayoutHelpPage(props: LayoutHelpPageProps) {
|
|
|
784
929
|
<HelpShell
|
|
785
930
|
session={session}
|
|
786
931
|
close={() => {}}
|
|
787
|
-
searchHelpApps={[]}
|
|
788
932
|
documents={props.documents}
|
|
789
933
|
includeShortcuts={props.includeShortcuts}
|
|
790
934
|
accent={props.accent}
|
|
@@ -795,19 +939,12 @@ export function LayoutHelpPage(props: LayoutHelpPageProps) {
|
|
|
795
939
|
);
|
|
796
940
|
}
|
|
797
941
|
|
|
798
|
-
|
|
942
|
+
/** Open the current app's Help over the page; the dialog sits outside the app canvas, so it takes the app's accent along. */
|
|
943
|
+
export function openLayoutHelpDialog() {
|
|
799
944
|
const session = createSession();
|
|
945
|
+
const accent = readShellData()?.accent;
|
|
800
946
|
void prompts.dialog<void>(
|
|
801
|
-
(close) =>
|
|
802
|
-
<HelpShell
|
|
803
|
-
session={session}
|
|
804
|
-
close={close}
|
|
805
|
-
searchHelpApps={searchHelpApps}
|
|
806
|
-
accent={accent}
|
|
807
|
-
surface="modal"
|
|
808
|
-
pageBase={window.__cloudLayoutHelpPageBase}
|
|
809
|
-
/>
|
|
810
|
-
),
|
|
947
|
+
(close) => <HelpShell session={session} close={close} accent={accent} surface="modal" pageBase={window.__cloudLayoutHelpPageBase} />,
|
|
811
948
|
{ surface: "bare", header: false, size: "wide" },
|
|
812
949
|
);
|
|
813
950
|
}
|