@k2b/cloud 0.37.0 → 0.38.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 +4 -2
- package/src/_internal/help-catalog.ts +123 -4
- package/src/_internal/help.ts +27 -6
- package/src/account/Avatar.tsx +20 -2
- package/src/account/ui.ts +1 -1
- package/src/ai/browser.ts +1 -0
- package/src/ai/chart-block.ts +44 -13
- package/src/ai/chat/chart-block.tsx +2 -11
- package/src/ai/chat/messages.ts +10 -0
- package/src/ai/chat/web-tools.tsx +23 -6
- package/src/ai/chat-tasks.ts +65 -1
- package/src/ai/default-tool-contracts.ts +30 -13
- package/src/ai/fetch-file-tool.ts +37 -3
- package/src/ai/migrate.ts +19 -2
- package/src/ai/readable-html.ts +1057 -0
- package/src/api/help.ts +23 -4
- package/src/api/mcp.ts +8 -2
- package/src/browser/FileChooser.tsx +11 -3
- package/src/browser/choose-files.browser-harness.tsx +4 -1
- package/src/browser/file-chooser-messages.ts +4 -0
- package/src/browser/file-providers.ts +14 -1
- package/src/browser/provider-folder.tsx +58 -5
- package/src/browser/resource-picker.tsx +5 -1
- package/src/cli/admin/workflows.ts +1 -0
- package/src/config/env.ts +14 -0
- package/src/contracts/file-provider.ts +48 -1
- package/src/contracts/outgoing-mail.ts +8 -1
- package/src/server/help.ts +136 -10
- package/src/server/services/geo.ts +21 -4
- package/src/services/allowed-network-fetch.ts +189 -0
- package/src/services/help/index.ts +46 -5
- package/src/services/help/store.ts +26 -5
- package/src/services/help/types.ts +10 -2
- package/src/services/index.ts +1 -0
- package/src/services/outgoing-mail/dispatcher.ts +7 -2
- package/src/services/outgoing-mail/resolve-host.ts +39 -0
- package/src/services/outgoing-mail/test-send.ts +18 -3
- package/src/services/outgoing-mail/transport.ts +24 -13
- package/src/services/settings/index.ts +48 -26
- package/src/shared/help.ts +41 -0
- package/src/shared/index.ts +9 -1
- package/src/shared/markdown/extensions/guided-help.ts +208 -64
- package/src/shared/markdown/formula.ts +8 -8
- package/src/shared/markdown/index.ts +72 -11
- package/src/ssr/Layout.tsx +20 -2
- package/src/ssr/LayoutHelp.browser-harness.tsx +130 -27
- package/src/ssr/LayoutHelp.tsx +569 -410
- package/src/ssr/ShellRuntime.island.tsx +17 -25
- package/src/ssr/help-messages.ts +60 -38
- package/src/ssr/help.ts +3 -0
- package/src/ssr/layout-help-article.ts +41 -0
- package/src/ssr/layout-help-navigation.ts +0 -12
- package/src/ssr/layout-help-registry.tsx +4 -11
- package/src/ssr/layout-help-search.ts +9 -12
- package/src/ssr/layout-help-start.ts +68 -0
- package/src/ssr/platform-messages.ts +4 -0
- package/src/ssr/shell-data.tsx +7 -0
- package/src/ssr/shell-shortcuts.ts +8 -0
- package/src/styles/effects.css +688 -185
- package/src/styles/resource-search.css +25 -0
- package/src/workflows/store/events.ts +24 -0
- package/src/workflows/store/index.ts +8 -0
- package/src/workflows/store/observability.ts +6 -1
- package/src/workflows/store/retention.ts +157 -0
package/src/ssr/LayoutHelp.tsx
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import type { HelpDocumentManifest, HelpDocumentPayload } from "@k2b/cloud/shared";
|
|
1
|
+
import type { HelpAccessLevel, HelpArticleLink, HelpDocumentManifest, HelpDocumentPayload } from "@k2b/cloud/shared";
|
|
2
2
|
import { clipboard } from "@k2b/stdlib/solid";
|
|
3
3
|
import {
|
|
4
4
|
Button,
|
|
5
|
+
Dropdown,
|
|
5
6
|
IconButton,
|
|
6
|
-
IconButtonLink,
|
|
7
7
|
Kbd,
|
|
8
8
|
MarkdownView,
|
|
9
9
|
NoticeCard,
|
|
@@ -11,19 +11,32 @@ import {
|
|
|
11
11
|
prefersReducedMotion,
|
|
12
12
|
prompts,
|
|
13
13
|
ScrollArea,
|
|
14
|
+
TableOfContents,
|
|
14
15
|
TextInput,
|
|
16
|
+
toast,
|
|
15
17
|
useLocale,
|
|
16
18
|
} from "@k2b/ui";
|
|
17
19
|
import { createEffect, createMemo, createSignal, For, type JSX, onCleanup, onMount, Show } from "solid-js";
|
|
18
20
|
import { COMMANDS_CHANGED, contextCommandsWithShortcuts } from "../browser/command-shortcuts";
|
|
21
|
+
import { HELP_ACCESS_LEVELS, HELP_KINDS } from "../shared/help";
|
|
19
22
|
import { appAccentStyle } from "./app-appearance";
|
|
20
23
|
import { helpMessages } from "./help-messages";
|
|
24
|
+
import { helpArticleParts } from "./layout-help-article";
|
|
21
25
|
import { formatHelpBundleMarkdown, formatHelpDocumentMarkdown } from "./layout-help-markdown";
|
|
22
|
-
import {
|
|
26
|
+
import { focusHelpArticleHeading, resetHelpArticleScroll } from "./layout-help-navigation";
|
|
23
27
|
import { HELP_TOPICS_EVENT, type HelpTopic, registry } from "./layout-help-registry";
|
|
24
28
|
import { searchHelpTopics } from "./layout-help-search";
|
|
29
|
+
import {
|
|
30
|
+
type HelpShortcutCommand,
|
|
31
|
+
type HelpShortcutScope,
|
|
32
|
+
type HelpStartGroupKind,
|
|
33
|
+
helpShortcutGroups,
|
|
34
|
+
helpStart,
|
|
35
|
+
} from "./layout-help-start";
|
|
25
36
|
import { helpLinkTarget, layoutHelpTopicHref } from "./layout-help-url";
|
|
37
|
+
import { platformMessages } from "./platform-messages";
|
|
26
38
|
import { readShellData, useShellData } from "./shell-data";
|
|
39
|
+
import { shellShortcuts } from "./shell-shortcuts";
|
|
27
40
|
|
|
28
41
|
export type LayoutHelpPageProps = {
|
|
29
42
|
documents: readonly HelpDocumentManifest[];
|
|
@@ -44,59 +57,98 @@ type HelpSession = {
|
|
|
44
57
|
articleScrollTop: number;
|
|
45
58
|
articleCache: Map<string, HelpDocumentPayload>;
|
|
46
59
|
};
|
|
47
|
-
type HelpSection = {
|
|
48
|
-
element: HTMLHeadingElement;
|
|
49
|
-
icon: string;
|
|
50
|
-
id: string;
|
|
51
|
-
title: string;
|
|
52
|
-
};
|
|
60
|
+
type HelpSection = { id: string; label: string };
|
|
53
61
|
|
|
54
62
|
const LAST_TOPIC_KEY = "cloud.layoutHelp.activeTopic";
|
|
55
63
|
|
|
56
|
-
const
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
64
|
+
const optionalString = (value: object, key: string): string | undefined => {
|
|
65
|
+
const field = Reflect.get(value, key);
|
|
66
|
+
return typeof field === "string" ? field : undefined;
|
|
67
|
+
};
|
|
68
|
+
/** A linked article from the Help API, or nothing when the server sent something else. */
|
|
69
|
+
const articleLink = (value: unknown): HelpArticleLink | undefined => {
|
|
70
|
+
if (!value || typeof value !== "object") return undefined;
|
|
71
|
+
const [appId, appName, documentId, title, url] = ["appId", "appName", "documentId", "title", "url"].map((key) =>
|
|
72
|
+
optionalString(value, key),
|
|
73
|
+
);
|
|
74
|
+
if (!appId || !appName || !documentId || !title || !url?.startsWith("/")) return undefined;
|
|
75
|
+
const [accent, description, icon] = ["accent", "description", "icon"].map((key) => optionalString(value, key));
|
|
76
|
+
return { appId, appName, accent, documentId, title, description, icon, url };
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
/** An article without its own icon gets a neutral page tile. */
|
|
80
|
+
const iconClass = (icon?: string) => (icon?.startsWith("ti ") ? icon : `ti ${icon ?? "ti-file-text"}`);
|
|
81
|
+
const byOrder = (a: HelpTopic, b: HelpTopic) => (a.order ?? 100) - (b.order ?? 100) || a.title.localeCompare(b.title);
|
|
82
|
+
const sortedTopics = () => [...(registry()?.values() ?? [])].sort(byOrder);
|
|
62
83
|
const mergeTopics = (registered: readonly HelpTopic[], documents: readonly HelpDocumentManifest[] = []): HelpTopic[] => {
|
|
63
84
|
const topics = new Map(registered.map((topic) => [topic.id, topic]));
|
|
64
|
-
for (const topic of
|
|
65
|
-
return [...topics.values()].sort(
|
|
85
|
+
for (const topic of documents) topics.set(topic.id, topic);
|
|
86
|
+
return [...topics.values()].sort(byOrder);
|
|
87
|
+
};
|
|
88
|
+
const GROUP_ICONS: Record<HelpStartGroupKind, string> = {
|
|
89
|
+
start: "ti ti-rocket",
|
|
90
|
+
reference: "ti ti-book-2",
|
|
91
|
+
troubleshoot: "ti ti-lifebuoy",
|
|
66
92
|
};
|
|
67
93
|
|
|
68
|
-
|
|
94
|
+
/**
|
|
95
|
+
* Keyboard shortcuts come last in the start. The browser knows the page's commands. On a Layout page (it has shell
|
|
96
|
+
* data) the shell always registers its own Search and Help shortcuts, so they show from the server render and every
|
|
97
|
+
* first client render on, also before the shell has registered them. The groups reserve their height and place in CSS,
|
|
98
|
+
* so the ones the browser adds fill space that is already there.
|
|
99
|
+
*/
|
|
100
|
+
const StartShortcuts = (props: { appName?: string }) => {
|
|
69
101
|
const locale = useLocale();
|
|
70
102
|
const t = () => helpMessages.resolve([locale()]).t;
|
|
71
|
-
const
|
|
103
|
+
const shell = useShellData();
|
|
104
|
+
const seed: HelpShortcutCommand[] = shell ? shellShortcuts(platformMessages.resolve([locale()]).t) : [];
|
|
105
|
+
const read = (): HelpShortcutCommand[] => {
|
|
106
|
+
const commands = contextCommandsWithShortcuts();
|
|
107
|
+
return [...seed.filter((command) => !commands.some((other) => other.id === command.id)), ...commands];
|
|
108
|
+
};
|
|
109
|
+
const [commands, setCommands] = createSignal<HelpShortcutCommand[]>(read());
|
|
72
110
|
onMount(() => {
|
|
73
|
-
const update = () => setCommands(
|
|
111
|
+
const update = () => setCommands(read());
|
|
112
|
+
update();
|
|
74
113
|
window.addEventListener(COMMANDS_CHANGED, update);
|
|
75
114
|
onCleanup(() => window.removeEventListener(COMMANDS_CHANGED, update));
|
|
76
115
|
});
|
|
77
|
-
const
|
|
116
|
+
const label = (scope: HelpShortcutScope) =>
|
|
117
|
+
scope === "everywhere"
|
|
118
|
+
? t().shortcutsEverywhere
|
|
119
|
+
: scope === "selection"
|
|
120
|
+
? t().shortcutsWithSelection
|
|
121
|
+
: props.appName
|
|
122
|
+
? t().shortcutsInApp({ app: props.appName })
|
|
123
|
+
: t().shortcutsInThisApp;
|
|
78
124
|
return (
|
|
79
|
-
<
|
|
80
|
-
<
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
</
|
|
84
|
-
<div class="
|
|
85
|
-
<For each={
|
|
86
|
-
{(
|
|
87
|
-
<
|
|
88
|
-
<
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
125
|
+
<section class="help-start-section help-start-shortcuts" data-help-shortcuts="" aria-labelledby="help-start-shortcuts-title">
|
|
126
|
+
<h3 id="help-start-shortcuts-title" class="help-start-heading">
|
|
127
|
+
<i class="ti ti-keyboard" aria-hidden="true" />
|
|
128
|
+
{t().keyboardShortcuts}
|
|
129
|
+
</h3>
|
|
130
|
+
<div class="help-shortcut-groups">
|
|
131
|
+
<For each={helpShortcutGroups(commands())}>
|
|
132
|
+
{(group) => (
|
|
133
|
+
<section class="help-shortcut-group" data-help-shortcut-group={group.scope} aria-label={label(group.scope)}>
|
|
134
|
+
<h4 class="help-shortcut-group-title" aria-hidden="true">
|
|
135
|
+
{label(group.scope)}
|
|
136
|
+
</h4>
|
|
137
|
+
<ul>
|
|
138
|
+
<For each={group.shortcuts}>
|
|
139
|
+
{(command) => (
|
|
140
|
+
<li class="help-shortcut" title={command.description}>
|
|
141
|
+
<span class="help-shortcut-title">{command.title}</span>
|
|
142
|
+
<Kbd keys={command.shortcut} platform={shell?.platform} />
|
|
143
|
+
</li>
|
|
144
|
+
)}
|
|
145
|
+
</For>
|
|
146
|
+
</ul>
|
|
147
|
+
</section>
|
|
96
148
|
)}
|
|
97
149
|
</For>
|
|
98
150
|
</div>
|
|
99
|
-
</
|
|
151
|
+
</section>
|
|
100
152
|
);
|
|
101
153
|
};
|
|
102
154
|
|
|
@@ -113,7 +165,11 @@ const HelpShell = (props: {
|
|
|
113
165
|
const locale = useLocale();
|
|
114
166
|
const t = () => helpMessages.resolve([locale()]).t;
|
|
115
167
|
// Links to this app's Help, by its own route or Core's `/help/apps/<id>`, open in place.
|
|
116
|
-
const
|
|
168
|
+
const shell = useShellData();
|
|
169
|
+
const appId = shell?.appId;
|
|
170
|
+
const app = shell?.apps.find((candidate) => candidate.id === appId);
|
|
171
|
+
const appName = app?.label;
|
|
172
|
+
const accessLabel = (level: HelpAccessLevel) => ({ view: t().accessView, edit: t().accessEdit, manage: t().accessManage })[level];
|
|
117
173
|
const [externalTopics, setExternalTopics] = createSignal(mergeTopics(sortedTopics(), props.documents));
|
|
118
174
|
const [view, setView] = createSignal<HelpView>(props.session.view);
|
|
119
175
|
const [query, setQuery] = createSignal(props.session.query);
|
|
@@ -124,12 +180,16 @@ const HelpShell = (props: {
|
|
|
124
180
|
const [loadAttempt, setLoadAttempt] = createSignal(0);
|
|
125
181
|
const [remoteIds, setRemoteIds] = createSignal<readonly string[] | null>(null);
|
|
126
182
|
const [searching, setSearching] = createSignal(false);
|
|
183
|
+
const [searchFailed, setSearchFailed] = createSignal(false);
|
|
184
|
+
const [searchAttempt, setSearchAttempt] = createSignal(0);
|
|
127
185
|
const [copyingAll, setCopyingAll] = createSignal(false);
|
|
128
|
-
const [copyAllError, setCopyAllError] = createSignal<string | null>(null);
|
|
129
|
-
const [articleSections, setArticleSections] = createSignal<HelpSection[]>([]);
|
|
130
186
|
const [activeSectionId, setActiveSectionId] = createSignal<string | null>(null);
|
|
131
|
-
const
|
|
132
|
-
const
|
|
187
|
+
const [tocScrollRoot, setTocScrollRoot] = createSignal<HTMLElement>();
|
|
188
|
+
const writeText = (text: string) => navigator.clipboard.writeText(text);
|
|
189
|
+
const articleClipboard = clipboard.createWriter({ write: writeText, copiedFor: 1800 });
|
|
190
|
+
const allHelpClipboard = clipboard.createWriter({ write: writeText, copiedFor: 1800 });
|
|
191
|
+
/** The article a search started from; clearing the search returns to it. */
|
|
192
|
+
let searchReturnId: string | null = null;
|
|
133
193
|
let root: HTMLDivElement | undefined;
|
|
134
194
|
let scrollArea: HTMLDivElement | undefined;
|
|
135
195
|
let articleContent: HTMLDivElement | undefined;
|
|
@@ -168,42 +228,32 @@ const HelpShell = (props: {
|
|
|
168
228
|
});
|
|
169
229
|
};
|
|
170
230
|
|
|
171
|
-
const syncActiveSection = (scroller = articleScroller()) => {
|
|
172
|
-
const sections = articleSections();
|
|
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);
|
|
176
|
-
let active = sections[0];
|
|
177
|
-
for (const section of sections) {
|
|
178
|
-
if (section.element.getBoundingClientRect().top <= readingLine) active = section;
|
|
179
|
-
else break;
|
|
180
|
-
}
|
|
181
|
-
setActiveSectionId(active?.id ?? null);
|
|
182
|
-
};
|
|
183
|
-
|
|
184
231
|
const scrollToHash = (hash: string) => {
|
|
185
232
|
const heading = Array.from(
|
|
186
233
|
articleContent?.querySelectorAll<HTMLHeadingElement>("h1[id], h2[id], h3[id], h4[id], h5[id], h6[id]") ?? [],
|
|
187
234
|
).find((element) => element.id === hash);
|
|
188
235
|
if (!heading) return false;
|
|
236
|
+
// A link to a heading in collapsed details opens them, and any details around them, first.
|
|
237
|
+
for (let details = heading.closest("details"); details; details = details.parentElement?.closest("details") ?? null)
|
|
238
|
+
details.open = true;
|
|
189
239
|
setActiveSectionId(hash);
|
|
190
240
|
heading.scrollIntoView({ block: "start", behavior: prefersReducedMotion() ? "auto" : "smooth" });
|
|
191
241
|
return true;
|
|
192
242
|
};
|
|
193
243
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
244
|
+
/** Once the article body is in the document: open the requested section, or start at the top. */
|
|
245
|
+
const syncTocScrollRoot = () => {
|
|
246
|
+
const scroller = articleScroller();
|
|
247
|
+
// The table of contents follows the window unless the reader scrolls in its own container.
|
|
248
|
+
setTocScrollRoot(scroller instanceof HTMLElement && scroller !== document.scrollingElement ? scroller : undefined);
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
const settleArticle = () => {
|
|
252
|
+
syncTocScrollRoot();
|
|
203
253
|
const openedSection = pendingHash ? scrollToHash(pendingHash) : false;
|
|
204
254
|
pendingHash = "";
|
|
205
255
|
pendingNavigation = false;
|
|
206
|
-
if (!openedSection)
|
|
256
|
+
if (!openedSection) setActiveSectionId(articleSections()[0]?.id ?? null);
|
|
207
257
|
};
|
|
208
258
|
|
|
209
259
|
const openArticleSection = (section: HelpSection) => {
|
|
@@ -216,26 +266,15 @@ const HelpShell = (props: {
|
|
|
216
266
|
}
|
|
217
267
|
};
|
|
218
268
|
|
|
219
|
-
const
|
|
220
|
-
|
|
221
|
-
requestAnimationFrame(() => searchInput?.focus({ preventScroll: true }));
|
|
222
|
-
};
|
|
223
|
-
|
|
224
|
-
const shortcutsTopic = createMemo<HelpTopic>(() => ({
|
|
225
|
-
id: "shortcuts",
|
|
226
|
-
title: t().shortcuts,
|
|
227
|
-
icon: "ti ti-keyboard",
|
|
228
|
-
description: t().shortcutsDescription,
|
|
229
|
-
order: 0,
|
|
230
|
-
kind: "content",
|
|
231
|
-
children: <Shortcuts focusSearch={focusSearch} />,
|
|
232
|
-
}));
|
|
233
|
-
const topics = createMemo(() => (props.includeShortcuts === false ? externalTopics() : [shortcutsTopic(), ...externalTopics()]));
|
|
234
|
-
const documentTopics = createMemo(() =>
|
|
235
|
-
externalTopics().filter((topic): topic is HelpDocumentManifest & { kind: "document" } => topic.kind === "document"),
|
|
236
|
-
);
|
|
269
|
+
const topics = externalTopics;
|
|
270
|
+
const start = createMemo(() => helpStart(topics(), props.surface === "modal" ? shell?.help : undefined));
|
|
237
271
|
const activeTopic = createMemo(() => topics().find((topic) => topic.id === activeId()) ?? null);
|
|
238
|
-
|
|
272
|
+
// Article bodies load in the browser only; the server renders the head and the loading state.
|
|
273
|
+
const articleParts = createMemo(() => {
|
|
274
|
+
const html = payload()?.html;
|
|
275
|
+
return html ? helpArticleParts(html) : null;
|
|
276
|
+
});
|
|
277
|
+
const articleSections = (): HelpSection[] => (view() === "article" ? articleParts()?.sections : undefined) ?? [];
|
|
239
278
|
const normalizedQuery = createMemo(() => query().trim().toLocaleLowerCase());
|
|
240
279
|
const results = createMemo(() => {
|
|
241
280
|
const value = normalizedQuery();
|
|
@@ -245,16 +284,12 @@ const HelpShell = (props: {
|
|
|
245
284
|
|
|
246
285
|
createEffect(() => {
|
|
247
286
|
const value = normalizedQuery();
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
externalTopics()
|
|
251
|
-
.filter((topic): topic is HelpDocumentManifest & { kind: "document" } => topic.kind === "document")
|
|
252
|
-
.map((topic) => topic.searchUrl),
|
|
253
|
-
),
|
|
254
|
-
];
|
|
287
|
+
searchAttempt();
|
|
288
|
+
const urls = [...new Set(topics().map((topic) => topic.searchUrl))];
|
|
255
289
|
const version = ++searchVersion;
|
|
256
290
|
setRemoteIds(null);
|
|
257
291
|
setSearching(false);
|
|
292
|
+
setSearchFailed(false);
|
|
258
293
|
if (!value || urls.length === 0) return;
|
|
259
294
|
|
|
260
295
|
const controller = new AbortController();
|
|
@@ -278,7 +313,8 @@ const HelpShell = (props: {
|
|
|
278
313
|
})
|
|
279
314
|
.catch((error) => {
|
|
280
315
|
if (error instanceof DOMException && error.name === "AbortError") return;
|
|
281
|
-
// Metadata matches stay usable if full-text search is temporarily unavailable.
|
|
316
|
+
// Metadata matches stay usable if full-text search is temporarily unavailable; the results say so.
|
|
317
|
+
if (version === searchVersion) setSearchFailed(true);
|
|
282
318
|
})
|
|
283
319
|
.finally(() => {
|
|
284
320
|
if (version === searchVersion) setSearching(false);
|
|
@@ -295,7 +331,6 @@ const HelpShell = (props: {
|
|
|
295
331
|
createEffect(() => {
|
|
296
332
|
const html = payload()?.html;
|
|
297
333
|
if (!html || view() !== "article") {
|
|
298
|
-
setArticleSections([]);
|
|
299
334
|
setActiveSectionId(null);
|
|
300
335
|
return;
|
|
301
336
|
}
|
|
@@ -304,7 +339,7 @@ const HelpShell = (props: {
|
|
|
304
339
|
const scroller = articleScroller();
|
|
305
340
|
if (scroller) scroller.scrollTop = 0;
|
|
306
341
|
}
|
|
307
|
-
|
|
342
|
+
settleArticle();
|
|
308
343
|
});
|
|
309
344
|
onCleanup(() => cancelAnimationFrame(frame));
|
|
310
345
|
});
|
|
@@ -328,27 +363,26 @@ const HelpShell = (props: {
|
|
|
328
363
|
|
|
329
364
|
onMount(() => {
|
|
330
365
|
const dialog = root?.closest("dialog");
|
|
331
|
-
|
|
332
|
-
dialog.setAttribute("aria-labelledby", "layout-help-title");
|
|
333
|
-
dialog.setAttribute("aria-describedby", "layout-help-subtitle");
|
|
334
|
-
}
|
|
366
|
+
dialog?.setAttribute("aria-labelledby", "layout-help-title");
|
|
335
367
|
const update = () => setExternalTopics(mergeTopics(sortedTopics(), props.documents));
|
|
336
368
|
window.addEventListener(HELP_TOPICS_EVENT, update);
|
|
337
369
|
update();
|
|
338
370
|
onCleanup(() => window.removeEventListener(HELP_TOPICS_EVENT, update));
|
|
339
371
|
restoreArticleScroll();
|
|
340
|
-
|
|
341
|
-
const onScroll = (
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
372
|
+
// The dialog reopens at the article position it had.
|
|
373
|
+
const onScroll = () => {
|
|
374
|
+
if (view() === "article" && scrollArea) props.session.articleScrollTop = scrollArea.scrollTop;
|
|
375
|
+
};
|
|
376
|
+
if (props.surface === "modal") scrollArea?.addEventListener("scroll", onScroll, { passive: true });
|
|
377
|
+
// Layout's breakpoints move page scrolling between its main column and the window; the contents follow.
|
|
378
|
+
let resizeFrame: number | undefined;
|
|
379
|
+
const onResize = () => {
|
|
380
|
+
resizeFrame ??= requestAnimationFrame(() => {
|
|
381
|
+
resizeFrame = undefined;
|
|
382
|
+
if (view() === "article") syncTocScrollRoot();
|
|
349
383
|
});
|
|
350
384
|
};
|
|
351
|
-
|
|
385
|
+
if (props.surface === "page") window.addEventListener("resize", onResize);
|
|
352
386
|
const onPopState = () => {
|
|
353
387
|
const address = pageAddress();
|
|
354
388
|
if (address === shownAddress) return;
|
|
@@ -362,9 +396,10 @@ const HelpShell = (props: {
|
|
|
362
396
|
};
|
|
363
397
|
if (props.syncPageUrl) window.addEventListener("popstate", onPopState);
|
|
364
398
|
onCleanup(() => {
|
|
365
|
-
|
|
399
|
+
scrollArea?.removeEventListener("scroll", onScroll);
|
|
400
|
+
window.removeEventListener("resize", onResize);
|
|
401
|
+
if (resizeFrame !== undefined) cancelAnimationFrame(resizeFrame);
|
|
366
402
|
window.removeEventListener("popstate", onPopState);
|
|
367
|
-
if (scrollFrame !== undefined) cancelAnimationFrame(scrollFrame);
|
|
368
403
|
});
|
|
369
404
|
});
|
|
370
405
|
onCleanup(() => copyAllController?.abort());
|
|
@@ -393,12 +428,21 @@ const HelpShell = (props: {
|
|
|
393
428
|
) {
|
|
394
429
|
throw new Error(t().invalidDocument);
|
|
395
430
|
}
|
|
431
|
+
const kind = Reflect.get(value, "kind");
|
|
432
|
+
const needs = Reflect.get(value, "needs");
|
|
433
|
+
const related = Reflect.get(value, "related");
|
|
434
|
+
const next = articleLink(Reflect.get(value, "next"));
|
|
396
435
|
const document: HelpDocumentPayload = {
|
|
397
436
|
id: topic.id,
|
|
398
437
|
title: value.title,
|
|
399
438
|
html: value.html,
|
|
400
439
|
markdown: value.markdown,
|
|
401
440
|
locale: "locale" in value && typeof value.locale === "string" ? value.locale : (topic.locale ?? "en"),
|
|
441
|
+
// A Core older than the app's Help reader sends no article facts; the article then shows none.
|
|
442
|
+
kind: HELP_KINDS.find((candidate) => candidate === kind) ?? "task",
|
|
443
|
+
needs: HELP_ACCESS_LEVELS.find((level) => level === needs),
|
|
444
|
+
...(next ? { next } : {}),
|
|
445
|
+
related: Array.isArray(related) ? related.flatMap((link) => articleLink(link) ?? []) : [],
|
|
402
446
|
};
|
|
403
447
|
props.session.articleCache.set(cacheKey, document);
|
|
404
448
|
return document;
|
|
@@ -411,7 +455,7 @@ const HelpShell = (props: {
|
|
|
411
455
|
setPayload(null);
|
|
412
456
|
setLoadError(null);
|
|
413
457
|
setLoading(false);
|
|
414
|
-
if (!topic ||
|
|
458
|
+
if (!topic || view() !== "article") return;
|
|
415
459
|
const cached = props.session.articleCache.get(`${topic.locale ?? "en"}:${topic.url}`);
|
|
416
460
|
if (cached) {
|
|
417
461
|
setPayload(cached);
|
|
@@ -441,20 +485,36 @@ const HelpShell = (props: {
|
|
|
441
485
|
});
|
|
442
486
|
});
|
|
443
487
|
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
488
|
+
/**
|
|
489
|
+
* Copies the open article. The article's own buttons show "Copied" in place; the menu closes on choice, so it confirms
|
|
490
|
+
* with a toast. A failed write always ends in an error toast with Try again.
|
|
491
|
+
*/
|
|
492
|
+
const copyArticle = async (topic: HelpTopic, document: HelpDocumentPayload | null, confirm = false) => {
|
|
493
|
+
if (!document) return;
|
|
494
|
+
if (await articleClipboard.copy(formatHelpDocumentMarkdown({ ...topic, markdown: document.markdown }))) {
|
|
495
|
+
if (confirm) toast.success(t().articleCopied);
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
toast.error(t().copyFailed({ message: articleClipboard.error()?.message ?? t().copyFallback }), {
|
|
499
|
+
action: { label: t().tryAgain, onClick: () => void copyArticle(topic, document, confirm) },
|
|
500
|
+
});
|
|
501
|
+
};
|
|
502
|
+
|
|
503
|
+
const copyFromMenu = async () => {
|
|
504
|
+
const topic = activeTopic();
|
|
505
|
+
if (view() === "article" && topic) await copyArticle(topic, payload(), true);
|
|
506
|
+
else await copyAllHelp();
|
|
447
507
|
};
|
|
448
508
|
|
|
449
509
|
const copyAllHelp = async () => {
|
|
450
|
-
const manifests =
|
|
510
|
+
const manifests = topics();
|
|
451
511
|
if (manifests.length === 0 || copyingAll()) return;
|
|
452
512
|
|
|
453
513
|
copyAllController?.abort();
|
|
454
514
|
const controller = new AbortController();
|
|
455
515
|
copyAllController = controller;
|
|
456
516
|
setCopyingAll(true);
|
|
457
|
-
|
|
517
|
+
const progress = toast(t().preparing, { progress: "indeterminate", duration: 0, iconClass: "ti ti-markdown" });
|
|
458
518
|
try {
|
|
459
519
|
const documents = await Promise.all(
|
|
460
520
|
manifests.map(async (topic) => {
|
|
@@ -462,10 +522,16 @@ const HelpShell = (props: {
|
|
|
462
522
|
return { ...topic, markdown: document.markdown };
|
|
463
523
|
}),
|
|
464
524
|
);
|
|
465
|
-
await allHelpClipboard.copy(formatHelpBundleMarkdown(documents))
|
|
525
|
+
if (!(await allHelpClipboard.copy(formatHelpBundleMarkdown(documents))))
|
|
526
|
+
throw allHelpClipboard.error() ?? new Error(t().copyFallback);
|
|
527
|
+
progress.dismiss();
|
|
528
|
+
toast.success(t().helpCopied);
|
|
466
529
|
} catch (error) {
|
|
530
|
+
progress.dismiss();
|
|
467
531
|
if (!(error instanceof DOMException && error.name === "AbortError")) {
|
|
468
|
-
|
|
532
|
+
toast.error(t().copyAllFailed({ message: error instanceof Error ? error.message : t().copyFallback }), {
|
|
533
|
+
action: { label: t().tryAgain, onClick: () => void copyAllHelp() },
|
|
534
|
+
});
|
|
469
535
|
}
|
|
470
536
|
} finally {
|
|
471
537
|
if (copyAllController === controller) {
|
|
@@ -482,6 +548,7 @@ const HelpShell = (props: {
|
|
|
482
548
|
}
|
|
483
549
|
};
|
|
484
550
|
const openTopic = (id: string, hash = "", push = true) => {
|
|
551
|
+
searchReturnId = null;
|
|
485
552
|
if (push) pushHelpUrl(id, hash);
|
|
486
553
|
try {
|
|
487
554
|
localStorage.setItem(LAST_TOPIC_KEY, id);
|
|
@@ -501,6 +568,7 @@ const HelpShell = (props: {
|
|
|
501
568
|
setView("article");
|
|
502
569
|
};
|
|
503
570
|
const showHub = (push = true) => {
|
|
571
|
+
searchReturnId = null;
|
|
504
572
|
setQuery("");
|
|
505
573
|
setResultsMinHeight(undefined);
|
|
506
574
|
setRemoteIds(null);
|
|
@@ -508,15 +576,38 @@ const HelpShell = (props: {
|
|
|
508
576
|
if (push) pushHelpUrl(null);
|
|
509
577
|
};
|
|
510
578
|
const goBack = () => {
|
|
579
|
+
searchReturnId = null;
|
|
511
580
|
setView("hub");
|
|
512
581
|
pushHelpUrl(null);
|
|
513
582
|
};
|
|
583
|
+
/** The search stays in the head in every view. Typing in an article shows results; clearing the search returns to the article. */
|
|
584
|
+
const changeQuery = (value: string) => {
|
|
585
|
+
const searching = !!value.trim();
|
|
586
|
+
if (searching && view() === "article") {
|
|
587
|
+
searchReturnId = activeId();
|
|
588
|
+
setResultsMinHeight(undefined);
|
|
589
|
+
setView("hub");
|
|
590
|
+
} else if (searching && !query().trim()) {
|
|
591
|
+
// Results replace the start below the field; the area keeps its height so nothing moves.
|
|
592
|
+
setResultsMinHeight(`${resultsRegion?.offsetHeight ?? 0}px`);
|
|
593
|
+
}
|
|
594
|
+
setQuery(value);
|
|
595
|
+
if (searching) return;
|
|
596
|
+
setResultsMinHeight(undefined);
|
|
597
|
+
const returnId = searchReturnId;
|
|
598
|
+
searchReturnId = null;
|
|
599
|
+
if (returnId && topics().some((topic) => topic.id === returnId)) {
|
|
600
|
+
setActiveId(returnId);
|
|
601
|
+
setView("article");
|
|
602
|
+
}
|
|
603
|
+
};
|
|
514
604
|
|
|
605
|
+
/** Links in the article, its table of contents, and its end open Help targets of this app in place. */
|
|
515
606
|
const followHelpLink = (event: MouseEvent) => {
|
|
516
607
|
if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
|
|
517
|
-
if (!(event.target instanceof Element)) return;
|
|
608
|
+
if (!(event.target instanceof Element) || !(event.currentTarget instanceof Element)) return;
|
|
518
609
|
const anchor = event.target.closest("a");
|
|
519
|
-
if (!anchor || !
|
|
610
|
+
if (!anchor || !event.currentTarget.contains(anchor) || (anchor.target && anchor.target !== "_self") || anchor.hasAttribute("download"))
|
|
520
611
|
return;
|
|
521
612
|
const target = helpLinkTarget(anchor.getAttribute("href") ?? "", location.href, { appId, pageBase: props.pageBase });
|
|
522
613
|
if (!target) return;
|
|
@@ -538,78 +629,96 @@ const HelpShell = (props: {
|
|
|
538
629
|
}
|
|
539
630
|
};
|
|
540
631
|
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
});
|
|
545
|
-
const modalDescription = createMemo(() => {
|
|
546
|
-
if (view() === "article") return activeTopic()?.description ?? t().description;
|
|
547
|
-
return t().description;
|
|
548
|
-
});
|
|
549
|
-
const modalIcon = createMemo(() => {
|
|
550
|
-
if (view() === "article") return iconClass(activeTopic()?.icon);
|
|
551
|
-
return "ti ti-help";
|
|
552
|
-
});
|
|
553
|
-
|
|
554
|
-
const TopicList = (listProps: { items: HelpTopic[] }) => (
|
|
555
|
-
<div class="flex flex-col gap-1 rounded-[var(--ui-radius-surface)] bg-[var(--ui-surface-subtle)] p-1.5">
|
|
632
|
+
/** Rows: icon tile, title, and description; the start's groups, its further tasks, and search results use them. */
|
|
633
|
+
const TopicList = (listProps: { items: HelpTopic[]; marker?: string }) => (
|
|
634
|
+
<ul class="help-start-rows" {...(listProps.marker ? { [listProps.marker]: "" } : {})}>
|
|
556
635
|
<For each={listProps.items}>
|
|
557
636
|
{(topic) => (
|
|
558
|
-
<
|
|
559
|
-
type="button"
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
<
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
</Show>
|
|
571
|
-
</span>
|
|
572
|
-
<i class="ti ti-chevron-right shrink-0 text-xs text-dimmed" />
|
|
573
|
-
</button>
|
|
637
|
+
<li>
|
|
638
|
+
<button type="button" class="help-start-row focus-ui" data-help-topic={topic.id} onClick={() => openTopic(topic.id)}>
|
|
639
|
+
<span class="help-topic-icon help-start-row-icon app-accent-text" aria-hidden="true">
|
|
640
|
+
<i class={iconClass(topic.icon)} />
|
|
641
|
+
</span>
|
|
642
|
+
<span class="help-article-link-copy">
|
|
643
|
+
<span class="help-article-link-title">{topic.title}</span>
|
|
644
|
+
<Show when={topic.description}>{(description) => <span class="help-article-link-description">{description()}</span>}</Show>
|
|
645
|
+
</span>
|
|
646
|
+
<i class="ti ti-chevron-right help-start-row-arrow" aria-hidden="true" />
|
|
647
|
+
</button>
|
|
648
|
+
</li>
|
|
574
649
|
)}
|
|
575
650
|
</For>
|
|
576
|
-
</
|
|
651
|
+
</ul>
|
|
577
652
|
);
|
|
578
653
|
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
class="
|
|
587
|
-
|
|
588
|
-
>
|
|
589
|
-
<span class="flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wide text-dimmed">
|
|
590
|
-
<i class="ti ti-arrow-left" aria-hidden="true" />
|
|
591
|
-
{t().previousTopic}
|
|
654
|
+
/** At most eight tasks as tiles; the app colour shows only in the icon. */
|
|
655
|
+
const TaskTiles = (tilesProps: { items: HelpTopic[] }) => (
|
|
656
|
+
<ul class="help-start-tiles" data-help-tiles="">
|
|
657
|
+
<For each={tilesProps.items}>
|
|
658
|
+
{(topic) => (
|
|
659
|
+
<li>
|
|
660
|
+
<button type="button" class="help-start-tile focus-ui" data-help-topic={topic.id} onClick={() => openTopic(topic.id)}>
|
|
661
|
+
<span class="help-topic-icon help-start-tile-icon app-accent-text" aria-hidden="true">
|
|
662
|
+
<i class={iconClass(topic.icon)} />
|
|
592
663
|
</span>
|
|
593
|
-
<span class="
|
|
664
|
+
<span class="help-start-tile-title">{topic.title}</span>
|
|
665
|
+
<Show when={topic.description}>{(description) => <span class="help-start-tile-description">{description()}</span>}</Show>
|
|
594
666
|
</button>
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
667
|
+
</li>
|
|
668
|
+
)}
|
|
669
|
+
</For>
|
|
670
|
+
</ul>
|
|
671
|
+
);
|
|
672
|
+
|
|
673
|
+
const groupTitle = (kind: HelpStartGroupKind) =>
|
|
674
|
+
({ start: t().groupStart, reference: t().groupReference, troubleshoot: t().groupTroubleshoot })[kind];
|
|
675
|
+
|
|
676
|
+
const ArticleLinkTitle = (link: { link: HelpArticleLink }) => (
|
|
677
|
+
<span class="help-article-link-copy">
|
|
678
|
+
<span class="help-article-link-title">{link.link.title}</span>
|
|
679
|
+
<Show when={link.link.description}>{(description) => <span class="help-article-link-description">{description()}</span>}</Show>
|
|
680
|
+
</span>
|
|
681
|
+
);
|
|
682
|
+
|
|
683
|
+
/** "Next" leads on; related articles keep their own app's colour, and another app says whose article it is. */
|
|
684
|
+
const ArticleEnd = (end: { document: HelpDocumentPayload }) => (
|
|
685
|
+
<Show when={end.document.next || end.document.related.length > 0}>
|
|
686
|
+
<footer class="help-article-end">
|
|
687
|
+
<Show when={end.document.next}>
|
|
598
688
|
{(next) => (
|
|
599
|
-
<
|
|
600
|
-
|
|
601
|
-
class="
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
{t().nextTopic}
|
|
606
|
-
<i class="ti ti-arrow-right" aria-hidden="true" />
|
|
689
|
+
<a class="help-article-next app-accent-scope focus-ui" href={next().url} style={appAccentStyle(next().accent)}>
|
|
690
|
+
<span class="help-article-next-label">{t().next}</span>
|
|
691
|
+
<span class="help-article-next-row">
|
|
692
|
+
<i class={`${iconClass(next().icon)} help-article-next-icon`} aria-hidden="true" />
|
|
693
|
+
<ArticleLinkTitle link={next()} />
|
|
694
|
+
<i class="ti ti-arrow-right help-article-next-arrow" aria-hidden="true" />
|
|
607
695
|
</span>
|
|
608
|
-
|
|
609
|
-
</button>
|
|
696
|
+
</a>
|
|
610
697
|
)}
|
|
611
698
|
</Show>
|
|
612
|
-
|
|
699
|
+
<Show when={end.document.related.length > 0}>
|
|
700
|
+
<section class="help-article-related" aria-labelledby="help-article-related-title">
|
|
701
|
+
<h4 id="help-article-related-title">{t().related}</h4>
|
|
702
|
+
<ul>
|
|
703
|
+
<For each={end.document.related}>
|
|
704
|
+
{(link) => (
|
|
705
|
+
<li>
|
|
706
|
+
<a class="help-article-related-link app-accent-scope focus-ui" href={link.url} style={appAccentStyle(link.accent)}>
|
|
707
|
+
<span class="help-topic-icon help-article-related-icon app-accent-text" aria-hidden="true">
|
|
708
|
+
<i class={iconClass(link.icon)} />
|
|
709
|
+
</span>
|
|
710
|
+
<ArticleLinkTitle link={link} />
|
|
711
|
+
<Show when={link.appId !== appId}>
|
|
712
|
+
<span class="help-article-related-app app-accent-text">{link.appName}</span>
|
|
713
|
+
</Show>
|
|
714
|
+
</a>
|
|
715
|
+
</li>
|
|
716
|
+
)}
|
|
717
|
+
</For>
|
|
718
|
+
</ul>
|
|
719
|
+
</section>
|
|
720
|
+
</Show>
|
|
721
|
+
</footer>
|
|
613
722
|
</Show>
|
|
614
723
|
);
|
|
615
724
|
|
|
@@ -622,278 +731,328 @@ const HelpShell = (props: {
|
|
|
622
731
|
</ScrollArea>
|
|
623
732
|
);
|
|
624
733
|
|
|
734
|
+
const pageHref = () => (props.pageBase ? layoutHelpTopicHref(props.pageBase, view() === "article" ? activeId() : null) : undefined);
|
|
735
|
+
const menuItems = () => [
|
|
736
|
+
{
|
|
737
|
+
label: t().copyAsMarkdown,
|
|
738
|
+
icon: "ti ti-markdown",
|
|
739
|
+
...(copyingAll() || (view() === "article" ? !payload() : topics().length === 0)
|
|
740
|
+
? { disabled: true as const }
|
|
741
|
+
: { action: () => void copyFromMenu() }),
|
|
742
|
+
},
|
|
743
|
+
...(props.surface === "modal" && pageHref() ? [{ label: t().openAsPage, icon: "ti ti-app-window", href: pageHref()! }] : []),
|
|
744
|
+
];
|
|
745
|
+
|
|
625
746
|
return (
|
|
626
747
|
<div
|
|
627
748
|
ref={root}
|
|
628
|
-
class={`app-accent-scope flex min-h-0 flex-col ${props.surface === "page" ? "" : "bg-[var(--ui-surface-raised)]"} ${
|
|
749
|
+
class={`help-shell app-accent-scope flex min-h-0 flex-col ${props.surface === "page" ? "" : "bg-[var(--ui-surface-raised)]"} ${
|
|
629
750
|
props.surface === "modal"
|
|
630
751
|
? "h-[min(48rem,var(--ui-dialog-available-height))] overflow-hidden panel-dialog-shell [box-shadow:var(--ui-shadow-float)]"
|
|
631
752
|
: props.surface === "page"
|
|
632
753
|
? ""
|
|
633
754
|
: "h-full"
|
|
634
755
|
}`}
|
|
756
|
+
data-surface={props.surface}
|
|
635
757
|
style={appAccentStyle(props.accent)}
|
|
636
758
|
>
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
{modalDescription()}
|
|
653
|
-
</p>
|
|
654
|
-
</div>
|
|
655
|
-
<div class="flex shrink-0 items-center gap-1" role="group" aria-label={t().actions}>
|
|
656
|
-
<Show when={view() === "hub" && documentTopics().length > 0}>
|
|
657
|
-
<IconButton
|
|
658
|
-
disabled={copyingAll()}
|
|
659
|
-
label={allHelpClipboard.wasCopied() ? t().helpCopied : t().copyAllHelp}
|
|
660
|
-
title={allHelpClipboard.wasCopied() ? t().copied : t().copyAllHelp}
|
|
661
|
-
onClick={() => void copyAllHelp()}
|
|
662
|
-
>
|
|
663
|
-
<i class={copyingAll() ? "ti ti-loader-2 animate-spin" : allHelpClipboard.wasCopied() ? "ti ti-check" : "ti ti-markdown"} />
|
|
664
|
-
</IconButton>
|
|
665
|
-
</Show>
|
|
666
|
-
<Show when={view() === "article" && activeTopic()?.kind === "document"}>
|
|
667
|
-
<IconButton
|
|
668
|
-
disabled={!payload()}
|
|
669
|
-
label={articleClipboard.wasCopied() ? t().articleCopied : t().copyArticle}
|
|
670
|
-
title={articleClipboard.wasCopied() ? t().copied : t().copyArticle}
|
|
671
|
-
onClick={() => {
|
|
672
|
-
const topic = activeTopic();
|
|
673
|
-
if (topic) void copyArticle(topic, payload());
|
|
674
|
-
}}
|
|
675
|
-
>
|
|
676
|
-
<i class={articleClipboard.wasCopied() ? "ti ti-check" : "ti ti-markdown"} />
|
|
677
|
-
</IconButton>
|
|
678
|
-
</Show>
|
|
679
|
-
<Show when={props.pageBase}>
|
|
680
|
-
{(pageBase) => (
|
|
681
|
-
<IconButtonLink
|
|
682
|
-
href={layoutHelpTopicHref(pageBase(), view() === "article" ? activeId() : null)}
|
|
683
|
-
target="_blank"
|
|
684
|
-
rel="noopener noreferrer"
|
|
685
|
-
label={t().openWindow}
|
|
686
|
-
title={t().openFullPage}
|
|
687
|
-
onClick={(event) => {
|
|
688
|
-
if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
|
|
689
|
-
|
|
690
|
-
const popup = window.open(
|
|
691
|
-
event.currentTarget.href,
|
|
692
|
-
"cloud-layout-help",
|
|
693
|
-
"popup,width=1120,height=820,resizable=yes,scrollbars=yes",
|
|
694
|
-
);
|
|
695
|
-
if (!popup) return;
|
|
696
|
-
|
|
697
|
-
event.preventDefault();
|
|
698
|
-
popup.opener = null;
|
|
699
|
-
popup.focus();
|
|
700
|
-
}}
|
|
759
|
+
{/* One head in every view: the search never moves between the start, an article, and results. An app's embedded
|
|
760
|
+
reader sits in the app's own chrome, which names it and picks its article, so it keeps only the search of its start. */}
|
|
761
|
+
<Show when={props.surface !== "embedded" || view() === "hub"}>
|
|
762
|
+
<header class="help-head" data-help-head="">
|
|
763
|
+
<div class="help-head-inner help-measure">
|
|
764
|
+
<Show when={props.surface !== "embedded"}>
|
|
765
|
+
<div class="help-head-row">
|
|
766
|
+
{/* The back button takes the app tile's place in an article, so nothing beside it moves. */}
|
|
767
|
+
<Show
|
|
768
|
+
when={view() === "article"}
|
|
769
|
+
fallback={
|
|
770
|
+
<span class="help-head-tile" aria-hidden="true">
|
|
771
|
+
<i class={app?.iconClass ?? "ti ti-help"} />
|
|
772
|
+
</span>
|
|
773
|
+
}
|
|
701
774
|
>
|
|
702
|
-
<
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
775
|
+
<IconButton class="help-head-back" label={t().backToTopics} title={t().back} onClick={goBack}>
|
|
776
|
+
<i class="ti ti-arrow-left" />
|
|
777
|
+
</IconButton>
|
|
778
|
+
</Show>
|
|
779
|
+
<h2 id="layout-help-title" class="help-head-title">
|
|
780
|
+
{appName ? t().helpFor({ app: appName }) : t().help}
|
|
781
|
+
</h2>
|
|
782
|
+
<div class="help-head-actions" role="group" aria-label={t().actions}>
|
|
783
|
+
<Dropdown.Root label={t().moreActions} position="bottom-left" items={menuItems()}>
|
|
784
|
+
<Dropdown.Trigger iconOnly label={t().moreActions} variant="ghost" data-help-menu="">
|
|
785
|
+
<i class="ti ti-dots" aria-hidden="true" />
|
|
786
|
+
</Dropdown.Trigger>
|
|
787
|
+
</Dropdown.Root>
|
|
788
|
+
<Show when={props.surface === "modal"}>
|
|
789
|
+
<IconButton label={t().closeHelp} title={t().close} onClick={props.close}>
|
|
790
|
+
<i class="ti ti-x" />
|
|
791
|
+
</IconButton>
|
|
792
|
+
</Show>
|
|
793
|
+
</div>
|
|
720
794
|
</div>
|
|
721
|
-
<Show when={props.surface !== "modal" && documentTopics().length > 0}>
|
|
722
|
-
<Button size="sm" variant="secondary" class="shrink-0" disabled={copyingAll()} onClick={() => void copyAllHelp()}>
|
|
723
|
-
<i class={copyingAll() ? "ti ti-loader-2 animate-spin" : allHelpClipboard.wasCopied() ? "ti ti-check" : "ti ti-copy"} />
|
|
724
|
-
{copyingAll() ? t().preparing : allHelpClipboard.wasCopied() ? t().copied : t().copyAllShort}
|
|
725
|
-
</Button>
|
|
726
|
-
</Show>
|
|
727
|
-
</div>
|
|
728
|
-
<Show when={copyAllError()}>
|
|
729
|
-
{(message) => (
|
|
730
|
-
<p class="text-xs text-danger" role="alert">
|
|
731
|
-
{t().copyAllFailed({ message: message() })}
|
|
732
|
-
</p>
|
|
733
|
-
)}
|
|
734
795
|
</Show>
|
|
735
796
|
<TextInput
|
|
736
797
|
ref={searchInput}
|
|
737
798
|
type="search"
|
|
799
|
+
class="help-head-search"
|
|
738
800
|
icon="ti ti-search"
|
|
739
801
|
aria-label={t().searchHelp}
|
|
740
802
|
data-help-search=""
|
|
741
803
|
value={query()}
|
|
742
804
|
placeholder={t().searchPlaceholder}
|
|
743
|
-
onValueChange={
|
|
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
|
-
}}
|
|
805
|
+
onValueChange={changeQuery}
|
|
749
806
|
/>
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
807
|
+
</div>
|
|
808
|
+
</header>
|
|
809
|
+
</Show>
|
|
810
|
+
|
|
811
|
+
<ReaderContent>
|
|
812
|
+
<Show when={view() === "hub"}>
|
|
813
|
+
<div ref={resultsRegion} data-help-results="" class="help-start help-measure" style={{ "min-height": resultsMinHeight() }}>
|
|
814
|
+
<Show
|
|
815
|
+
when={normalizedQuery()}
|
|
816
|
+
fallback={
|
|
817
|
+
<>
|
|
818
|
+
<Show when={start().view.length > 0}>
|
|
819
|
+
<section class="help-start-section" data-help-view="" aria-labelledby="help-start-view-title">
|
|
820
|
+
<h3 id="help-start-view-title" class="help-start-heading">
|
|
821
|
+
<i class="ti ti-focus-2" aria-hidden="true" />
|
|
822
|
+
{t().forThisView}
|
|
823
|
+
</h3>
|
|
824
|
+
<TopicList items={start().view} />
|
|
760
825
|
</section>
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
</
|
|
768
|
-
|
|
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} />
|
|
826
|
+
</Show>
|
|
827
|
+
<Show when={start().tiles.length > 0}>
|
|
828
|
+
<section class="help-start-section" data-help-tasks="" aria-labelledby="help-start-tasks-title">
|
|
829
|
+
<h3 id="help-start-tasks-title" class="help-start-heading">
|
|
830
|
+
<i class="ti ti-checklist" aria-hidden="true" />
|
|
831
|
+
{t().whatToDo}
|
|
832
|
+
</h3>
|
|
833
|
+
<TaskTiles items={start().tiles} />
|
|
834
|
+
<Show when={start().moreTasks.length > 0}>
|
|
835
|
+
<TopicList items={start().moreTasks} marker="data-help-more-tasks" />
|
|
785
836
|
</Show>
|
|
786
|
-
|
|
787
|
-
>
|
|
788
|
-
|
|
837
|
+
</section>
|
|
838
|
+
</Show>
|
|
839
|
+
<Show when={start().groups.length > 0}>
|
|
840
|
+
<div class="help-start-groups">
|
|
841
|
+
<For each={start().groups}>
|
|
842
|
+
{(group) => (
|
|
843
|
+
<section
|
|
844
|
+
class="help-start-section"
|
|
845
|
+
data-help-group={group.kind}
|
|
846
|
+
aria-labelledby={`help-start-${group.kind}-title`}
|
|
847
|
+
>
|
|
848
|
+
<h3 id={`help-start-${group.kind}-title`} class="help-start-heading">
|
|
849
|
+
<i class={GROUP_ICONS[group.kind]} aria-hidden="true" />
|
|
850
|
+
{groupTitle(group.kind)}
|
|
851
|
+
</h3>
|
|
852
|
+
<TopicList items={group.topics} />
|
|
853
|
+
</section>
|
|
854
|
+
)}
|
|
855
|
+
</For>
|
|
856
|
+
</div>
|
|
857
|
+
</Show>
|
|
858
|
+
{/* An app without Help says so plainly; only a failed read of its Help offers a way to try again. */}
|
|
859
|
+
<Show when={topics().length === 0}>
|
|
860
|
+
<Show
|
|
861
|
+
when={shell?.helpFailed}
|
|
862
|
+
fallback={<Placeholder icon="ti ti-book-off" title={t().noArticles} description={t().noArticlesDetail} />}
|
|
863
|
+
>
|
|
864
|
+
<NoticeCard tone="danger" title={t().helpUnavailable} detail={t().helpUnavailableDetail} data-help-unavailable="">
|
|
865
|
+
<Button size="sm" variant="secondary" onClick={() => location.reload()}>
|
|
866
|
+
{t().reload}
|
|
867
|
+
</Button>
|
|
868
|
+
</NoticeCard>
|
|
869
|
+
</Show>
|
|
870
|
+
</Show>
|
|
871
|
+
<Show when={props.includeShortcuts !== false}>
|
|
872
|
+
<StartShortcuts appName={appName} />
|
|
873
|
+
</Show>
|
|
874
|
+
</>
|
|
875
|
+
}
|
|
876
|
+
>
|
|
877
|
+
{/* The count takes the place of the first heading, so the first row stays where it was. */}
|
|
878
|
+
<section class="help-start-section" data-help-search-results="">
|
|
879
|
+
{/* A failed full-text search says so in the same line, with a way to try again; the results do not move. */}
|
|
880
|
+
<div class="help-start-status">
|
|
881
|
+
<span aria-live="polite">{t().results({ count: results().length })}</span>
|
|
882
|
+
<Show when={searching()}>
|
|
883
|
+
<i class="ti ti-loader-2 animate-spin" aria-hidden="true" />
|
|
884
|
+
</Show>
|
|
885
|
+
<Show when={searchFailed()}>
|
|
886
|
+
<span class="help-start-status-warning" data-help-search-failed="" title={t().searchTitlesOnly} role="status">
|
|
887
|
+
<i class="ti ti-alert-triangle" aria-hidden="true" />
|
|
888
|
+
<span aria-hidden="true">{t().titlesOnly}</span>
|
|
889
|
+
<span class="sr-only">{t().searchTitlesOnly}</span>
|
|
890
|
+
</span>
|
|
891
|
+
<Button
|
|
892
|
+
size="xs"
|
|
893
|
+
variant="ghost"
|
|
894
|
+
class="help-start-status-retry"
|
|
895
|
+
onClick={() => setSearchAttempt((value) => value + 1)}
|
|
896
|
+
>
|
|
897
|
+
{t().tryAgain}
|
|
898
|
+
</Button>
|
|
789
899
|
</Show>
|
|
790
|
-
</
|
|
791
|
-
|
|
792
|
-
|
|
900
|
+
</div>
|
|
901
|
+
<Show
|
|
902
|
+
when={results().length > 0}
|
|
903
|
+
fallback={
|
|
904
|
+
<Show when={!searching()}>
|
|
905
|
+
<Placeholder icon="ti ti-search-off" title={t().noResults} />
|
|
906
|
+
</Show>
|
|
907
|
+
}
|
|
908
|
+
>
|
|
909
|
+
<TopicList items={results()} />
|
|
910
|
+
</Show>
|
|
911
|
+
</section>
|
|
912
|
+
</Show>
|
|
793
913
|
</div>
|
|
794
914
|
</Show>
|
|
795
915
|
|
|
796
916
|
<Show when={view() === "article" && activeTopic()}>
|
|
797
917
|
{(topic) => (
|
|
798
|
-
<article
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
918
|
+
<article
|
|
919
|
+
class="help-article"
|
|
920
|
+
data-surface={props.surface}
|
|
921
|
+
ref={(element) => {
|
|
922
|
+
// Delegated from the article: its body, table of contents, and end hold the links.
|
|
923
|
+
element.addEventListener("click", followHelpLink);
|
|
924
|
+
onCleanup(() => element.removeEventListener("click", followHelpLink));
|
|
925
|
+
}}
|
|
926
|
+
>
|
|
927
|
+
<div class="help-article-layout">
|
|
928
|
+
<div class="help-article-main">
|
|
929
|
+
<header class="help-article-head">
|
|
930
|
+
<p class="help-article-path">
|
|
931
|
+
<Show when={appName}>
|
|
932
|
+
{(name) => (
|
|
933
|
+
<>
|
|
934
|
+
<span class="app-accent-text">{name()}</span>
|
|
935
|
+
<i class="ti ti-chevron-right" aria-hidden="true" />
|
|
936
|
+
</>
|
|
937
|
+
)}
|
|
938
|
+
</Show>
|
|
939
|
+
<button type="button" class="help-article-path-link focus-ui" onClick={goBack}>
|
|
940
|
+
{t().help}
|
|
941
|
+
</button>
|
|
942
|
+
</p>
|
|
943
|
+
<h3 ref={articleHeading} tabindex="-1" class="help-article-title focus-ui">
|
|
813
944
|
{topic().title}
|
|
814
945
|
</h3>
|
|
815
|
-
<Show
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
class="col-start-2 shrink-0 justify-self-start sm:col-start-3 sm:row-start-1 sm:justify-self-auto"
|
|
824
|
-
onClick={() => void copyArticle(topic(), payload())}
|
|
946
|
+
<Show
|
|
947
|
+
when={articleParts()?.lead}
|
|
948
|
+
fallback={
|
|
949
|
+
// An article without a leading paragraph, or a built-in topic, shows its description; a loading article shows neither yet.
|
|
950
|
+
<Show when={articleParts() && topic().description}>
|
|
951
|
+
{(description) => <p class="help-article-lead">{description()}</p>}
|
|
952
|
+
</Show>
|
|
953
|
+
}
|
|
825
954
|
>
|
|
826
|
-
<
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
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} />
|
|
955
|
+
{(lead) => <MarkdownView trustedHtml={lead()} class="help-document help-article-lead" />}
|
|
956
|
+
</Show>
|
|
957
|
+
<Show when={payload()?.needs || articleParts()?.steps}>
|
|
958
|
+
{/* One row of facts; the access level appears here and nowhere else in the reader's chrome. */}
|
|
959
|
+
<ul class="help-article-facts" aria-label={t().articleFacts}>
|
|
960
|
+
<Show when={payload()?.needs}>
|
|
961
|
+
{(level) => (
|
|
962
|
+
<li class="help-article-fact" data-fact="needs">
|
|
963
|
+
<i class="ti ti-key" aria-hidden="true" />
|
|
964
|
+
<span>
|
|
965
|
+
{t().youNeed} <strong>{accessLabel(level())}</strong>
|
|
854
966
|
</span>
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
)}
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
</
|
|
967
|
+
</li>
|
|
968
|
+
)}
|
|
969
|
+
</Show>
|
|
970
|
+
<Show when={articleParts()?.steps}>
|
|
971
|
+
{(count) => (
|
|
972
|
+
<li class="help-article-fact" data-fact="steps">
|
|
973
|
+
<i class="ti ti-list-numbers" aria-hidden="true" />
|
|
974
|
+
<span>{t().steps({ count: count() })}</span>
|
|
975
|
+
</li>
|
|
976
|
+
)}
|
|
977
|
+
</Show>
|
|
978
|
+
</ul>
|
|
867
979
|
</Show>
|
|
868
|
-
<Show when={
|
|
869
|
-
|
|
870
|
-
<
|
|
871
|
-
<
|
|
872
|
-
{t().
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
980
|
+
<Show when={articleSections().length > 0 || props.surface !== "modal"}>
|
|
981
|
+
<div class="help-article-tools">
|
|
982
|
+
<Show when={articleSections().length > 0}>
|
|
983
|
+
<Dropdown.Root
|
|
984
|
+
label={t().onThisPage}
|
|
985
|
+
position="bottom-left"
|
|
986
|
+
items={articleSections().map((section) => ({
|
|
987
|
+
label: section.label,
|
|
988
|
+
choice: "radio" as const,
|
|
989
|
+
checked: () => activeSectionId() === section.id,
|
|
990
|
+
action: () => openArticleSection(section),
|
|
991
|
+
}))}
|
|
992
|
+
>
|
|
993
|
+
<Dropdown.Trigger label={t().onThisPage} size="sm" tooltip={false} variant="secondary">
|
|
994
|
+
<i class="ti ti-list" aria-hidden="true" />
|
|
995
|
+
<span>{t().onThisPage}</span>
|
|
996
|
+
<i class="ti ti-chevron-down" aria-hidden="true" />
|
|
997
|
+
</Dropdown.Trigger>
|
|
998
|
+
</Dropdown.Root>
|
|
999
|
+
</Show>
|
|
1000
|
+
<Show when={props.surface !== "modal"}>
|
|
1001
|
+
<IconButton
|
|
1002
|
+
class="help-article-tools-copy"
|
|
1003
|
+
disabled={!payload()}
|
|
1004
|
+
label={articleClipboard.wasCopied() ? t().articleCopied : t().copyArticle}
|
|
1005
|
+
title={articleClipboard.wasCopied() ? t().copied : t().copyArticle}
|
|
1006
|
+
onClick={() => void copyArticle(topic(), payload())}
|
|
1007
|
+
>
|
|
1008
|
+
<i class={articleClipboard.wasCopied() ? "ti ti-check" : "ti ti-markdown"} />
|
|
1009
|
+
</IconButton>
|
|
1010
|
+
</Show>
|
|
1011
|
+
</div>
|
|
876
1012
|
</Show>
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
onCleanup(() => element.removeEventListener("click", followHelpLink));
|
|
882
|
-
}}
|
|
883
|
-
>
|
|
884
|
-
<Show when={payload()}>{(document) => <MarkdownView trustedHtml={document().html} class="help-document" />}</Show>
|
|
1013
|
+
</header>
|
|
1014
|
+
<Show when={loading()}>
|
|
1015
|
+
<div class="min-h-[24rem]">
|
|
1016
|
+
<Placeholder state="loading" variant="panel" title={t().loading} />
|
|
885
1017
|
</div>
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
1018
|
+
</Show>
|
|
1019
|
+
<Show when={loadError()}>
|
|
1020
|
+
{(message) => (
|
|
1021
|
+
<NoticeCard tone="danger" title={t().loadFailed} detail={message()}>
|
|
1022
|
+
<Button size="sm" variant="secondary" onClick={() => setLoadAttempt((value) => value + 1)}>
|
|
1023
|
+
{t().tryAgain}
|
|
1024
|
+
</Button>
|
|
1025
|
+
</NoticeCard>
|
|
1026
|
+
)}
|
|
1027
|
+
</Show>
|
|
1028
|
+
<div ref={articleContent}>
|
|
1029
|
+
<Show when={articleParts()}>{(parts) => <MarkdownView trustedHtml={parts().body} class="help-document" />}</Show>
|
|
889
1030
|
</div>
|
|
1031
|
+
<Show when={payload()}>{(document) => <ArticleEnd document={document()} />}</Show>
|
|
890
1032
|
</div>
|
|
891
|
-
|
|
1033
|
+
<aside class="help-article-aside">
|
|
1034
|
+
<div class="help-article-aside-sticky">
|
|
1035
|
+
<TableOfContents
|
|
1036
|
+
items={articleSections()}
|
|
1037
|
+
title={t().onThisPage}
|
|
1038
|
+
scrollRoot={tocScrollRoot}
|
|
1039
|
+
contentRoot={() => articleContent}
|
|
1040
|
+
onActiveChange={(id) => setActiveSectionId(id ?? null)}
|
|
1041
|
+
/>
|
|
1042
|
+
<Show when={props.surface !== "modal"}>
|
|
1043
|
+
<Button size="sm" variant="ghost" disabled={!payload()} onClick={() => void copyArticle(topic(), payload())}>
|
|
1044
|
+
<i class={articleClipboard.wasCopied() ? "ti ti-check" : "ti ti-markdown"} />
|
|
1045
|
+
{articleClipboard.wasCopied() ? t().copied : t().copyMarkdown}
|
|
1046
|
+
</Button>
|
|
1047
|
+
</Show>
|
|
1048
|
+
</div>
|
|
1049
|
+
</aside>
|
|
1050
|
+
</div>
|
|
892
1051
|
</article>
|
|
893
1052
|
)}
|
|
894
1053
|
</Show>
|
|
895
1054
|
<span class="sr-only" aria-live="polite">
|
|
896
|
-
{articleClipboard.wasCopied() ? t().articleCopiedAnnouncement :
|
|
1055
|
+
{articleClipboard.wasCopied() ? t().articleCopiedAnnouncement : ""}
|
|
897
1056
|
</span>
|
|
898
1057
|
</ReaderContent>
|
|
899
1058
|
</div>
|