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