@jant/core 0.6.15 → 0.6.16
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/bin/commands/import-site.js +7 -0
- package/dist/app-C75Zc8dD.js +6 -0
- package/dist/{app-C-oi_t8W.js → app-C7_4oab9.js} +480 -196
- package/dist/client/.vite/manifest.json +8 -8
- package/dist/client/_assets/chunks/{url-pLre2DM_.js → url-CU9pEbk5.js} +1 -1
- package/dist/client/_assets/{client-DxY5BFe8.js → client-Bq0vre8Y.js} +3 -3
- package/dist/client/_assets/client-D3VZyG4L.css +2 -0
- package/dist/client/_assets/{client-auth-CakPESv9.js → client-auth-F2rb9WfF.js} +1015 -977
- package/dist/{env-OHRKGcMj.js → env-BPYViDJJ.js} +1 -1
- package/dist/{export-CsFx6F_M.js → export-Dl5KCiEs.js} +93 -7
- package/dist/{github-api-BgSiE71w.js → github-api-BNF35Lkx.js} +1 -1
- package/dist/{github-app-BbklkFmU.js → github-app-DdiD-bC4.js} +1 -1
- package/dist/{github-sync-ppWXN3jb.js → github-sync-Bqg62IvV.js} +3 -3
- package/dist/{github-sync-Cq1pzzOI.js → github-sync-HdK2EgvJ.js} +3 -3
- package/dist/index.js +5 -5
- package/dist/node.js +6 -6
- package/dist/{url-BMYO-Zlt.js → url-CbLAtlZe.js} +517 -12
- package/package.json +1 -1
- package/src/__tests__/mise-config.test.ts +22 -0
- package/src/client/__tests__/compose-bridge.test.ts +252 -2
- package/src/client/__tests__/compose-shortcuts.test.ts +20 -0
- package/src/client/__tests__/toast.test.ts +65 -1
- package/src/client/components/__tests__/compose-format-convert.test.ts +0 -19
- package/src/client/components/__tests__/jant-command-palette.test.ts +51 -0
- package/src/client/components/__tests__/jant-compose-dialog.test.ts +2015 -318
- package/src/client/components/__tests__/jant-compose-editor.test.ts +53 -62
- package/src/client/components/__tests__/jant-compose-fullscreen.test.ts +35 -89
- package/src/client/components/compose-format-convert.ts +5 -1
- package/src/client/components/compose-types.ts +47 -3
- package/src/client/components/jant-command-palette.ts +12 -6
- package/src/client/components/jant-compose-dialog.ts +1464 -680
- package/src/client/components/jant-compose-editor.ts +225 -93
- package/src/client/components/jant-compose-fullscreen.ts +3 -5
- package/src/client/components/jant-post-menu.ts +91 -21
- package/src/client/compose-bridge.ts +143 -122
- package/src/client/compose-launch.ts +13 -0
- package/src/client/compose-shortcuts.ts +5 -1
- package/src/client/post-refresh.ts +135 -0
- package/src/client/tiptap/__tests__/link-input-rules.test.ts +121 -0
- package/src/client/tiptap/link-input-rules.ts +4 -4
- package/src/client/toast.ts +130 -38
- package/src/db/__tests__/backfill-thread-activity.test.ts +234 -0
- package/src/db/__tests__/thread-activity.test.ts +113 -0
- package/src/db/backfills/0005_split_thread_activity_from_quiet_replies.sql +68 -0
- package/src/db/migrations/0031_many_ego.sql +3 -0
- package/src/db/migrations/meta/0031_snapshot.json +2562 -0
- package/src/db/migrations/meta/_journal.json +7 -0
- package/src/db/migrations/pg/0029_rare_hairball.sql +3 -0
- package/src/db/migrations/pg/meta/0029_snapshot.json +3289 -0
- package/src/db/migrations/pg/meta/_journal.json +7 -0
- package/src/db/pg/schema.ts +18 -0
- package/src/db/schema.ts +20 -0
- package/src/db/thread-activity.ts +101 -0
- package/src/i18n/locales/public/en.po +89 -21
- package/src/i18n/locales/public/en.ts +1 -1
- package/src/i18n/locales/public/zh-Hans.po +88 -20
- package/src/i18n/locales/public/zh-Hans.ts +1 -1
- package/src/i18n/locales/public/zh-Hant.po +88 -20
- package/src/i18n/locales/public/zh-Hant.ts +1 -1
- package/src/lib/__tests__/feed.test.ts +168 -0
- package/src/lib/__tests__/markdown.test.ts +16 -0
- package/src/lib/__tests__/slug.test.ts +18 -0
- package/src/lib/__tests__/translit.test.ts +87 -0
- package/src/lib/__tests__/url.test.ts +53 -0
- package/src/lib/feed.ts +113 -11
- package/src/lib/hugo-markdown.ts +5 -0
- package/src/lib/link-preview.ts +25 -0
- package/src/lib/markdown-manager.ts +6 -1
- package/src/lib/post-display.ts +42 -9
- package/src/lib/timeline.ts +39 -9
- package/src/lib/tiptap-render.ts +4 -2
- package/src/lib/translit.ts +288 -0
- package/src/lib/url.ts +123 -12
- package/src/lib/view.ts +28 -0
- package/src/routes/api/__tests__/palette.test.ts +44 -0
- package/src/routes/api/posts.ts +13 -9
- package/src/routes/api/public/__tests__/posts.test.ts +38 -0
- package/src/routes/api/public/posts.ts +7 -0
- package/src/routes/compose.tsx +6 -2
- package/src/routes/feed/__tests__/sitemap.test.ts +68 -1
- package/src/routes/pages/__tests__/archive-params.test.ts +292 -0
- package/src/routes/pages/__tests__/featured.test.ts +6 -2
- package/src/routes/pages/__tests__/preview.test.ts +3 -1
- package/src/routes/pages/archive.tsx +65 -17
- package/src/routes/pages/page.tsx +5 -2
- package/src/services/__tests__/collection.test.ts +32 -1
- package/src/services/__tests__/post-timeline.test.ts +142 -11
- package/src/services/__tests__/post.test.ts +277 -0
- package/src/services/collection.ts +8 -10
- package/src/services/export-theme/assets/client-site.css +1 -1
- package/src/services/export.ts +3 -5
- package/src/services/path.ts +4 -1
- package/src/services/post.ts +297 -100
- package/src/services/search.ts +9 -0
- package/src/styles/components.css +0 -14
- package/src/styles/site-media.css +4 -4
- package/src/styles/tokens.css +62 -1
- package/src/styles/ui.css +1363 -1283
- package/src/types/entities.ts +5 -0
- package/src/types/props.ts +12 -0
- package/src/types/views.ts +6 -0
- package/src/ui/compose/ComposeDialog.tsx +17 -17
- package/src/ui/feed/LinkPreview.tsx +2 -7
- package/src/ui/feed/PostStatusBadges.tsx +42 -3
- package/src/ui/pages/ArchivePage.tsx +131 -24
- package/src/ui/pages/__tests__/ArchivePage.test.tsx +63 -0
- package/src/ui/shared/DraftPreviewBar.tsx +25 -9
- package/src/ui/shared/PostFooter.tsx +35 -12
- package/src/ui/shared/custom-icons.ts +5 -0
- package/src/ui/shared/post-article-attributes.ts +6 -0
- package/dist/app-BxCOR3Uc.js +0 -6
- package/dist/client/_assets/client-B_eGKN5p.css +0 -2
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-place refresh of a rendered Post.
|
|
3
|
+
*
|
|
4
|
+
* Reply submission, draft publishing, and anything else that changes a Post
|
|
5
|
+
* server-side needs the same thing afterwards: re-render exactly the surface
|
|
6
|
+
* the Post is shown on — a thread page, a feed item, or a standalone card —
|
|
7
|
+
* without a full page load.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { hydratePartial } from "./hydrate-partial.js";
|
|
11
|
+
import { getReplyRefreshTarget } from "./compose-launch.js";
|
|
12
|
+
|
|
13
|
+
async function fetchPartialHtml(path: string): Promise<string | null> {
|
|
14
|
+
const res = await fetch(path, {
|
|
15
|
+
headers: { Accept: "text/html" },
|
|
16
|
+
});
|
|
17
|
+
if (!res.ok) return null;
|
|
18
|
+
return res.text();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function refreshTimelineThreadView(
|
|
22
|
+
threadRootId: string,
|
|
23
|
+
): Promise<boolean> {
|
|
24
|
+
try {
|
|
25
|
+
const timelineItem = document.querySelector<HTMLElement>(
|
|
26
|
+
`[data-timeline-item][data-thread-root-id="${threadRootId}"]`,
|
|
27
|
+
);
|
|
28
|
+
const content = timelineItem?.querySelector<HTMLElement>(
|
|
29
|
+
"[data-timeline-item-content]",
|
|
30
|
+
);
|
|
31
|
+
if (!content) return false;
|
|
32
|
+
|
|
33
|
+
const html = await fetchPartialHtml(
|
|
34
|
+
`/_/timeline-item/${encodeURIComponent(threadRootId)}`,
|
|
35
|
+
);
|
|
36
|
+
if (!html) return false;
|
|
37
|
+
|
|
38
|
+
content.innerHTML = html;
|
|
39
|
+
// Swapped-in markup carries interactions whose per-element setup only runs
|
|
40
|
+
// on DOMContentLoaded (thread "Show more" toggle, feed video autoplay, audio
|
|
41
|
+
// waveform); re-initialize them or they stay inert until a full reload.
|
|
42
|
+
hydratePartial(content);
|
|
43
|
+
return true;
|
|
44
|
+
} catch {
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function refreshPostCardView(postId: string): Promise<boolean> {
|
|
50
|
+
try {
|
|
51
|
+
const timelineItem = document
|
|
52
|
+
.querySelector<HTMLElement>(`article[data-post-id="${postId}"]`)
|
|
53
|
+
?.closest<HTMLElement>("[data-timeline-item]");
|
|
54
|
+
const html = await fetchPartialHtml(
|
|
55
|
+
`/_/post-card/${encodeURIComponent(postId)}`,
|
|
56
|
+
);
|
|
57
|
+
if (!html) return false;
|
|
58
|
+
|
|
59
|
+
if (timelineItem) {
|
|
60
|
+
const content = timelineItem.querySelector<HTMLElement>(
|
|
61
|
+
"[data-timeline-item-content]",
|
|
62
|
+
);
|
|
63
|
+
if (!content) return false;
|
|
64
|
+
content.innerHTML = html;
|
|
65
|
+
hydratePartial(content);
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const article = document.querySelector<HTMLElement>(
|
|
70
|
+
`article[data-post-id="${postId}"]`,
|
|
71
|
+
);
|
|
72
|
+
if (!article) return false;
|
|
73
|
+
|
|
74
|
+
article.outerHTML = html;
|
|
75
|
+
// outerHTML detaches `article`; re-query the replacement to hydrate it.
|
|
76
|
+
const nextArticle = document.querySelector<HTMLElement>(
|
|
77
|
+
`article[data-post-id="${postId}"]`,
|
|
78
|
+
);
|
|
79
|
+
if (nextArticle) hydratePartial(nextArticle);
|
|
80
|
+
return true;
|
|
81
|
+
} catch {
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export async function refreshPostPageView(postId: string): Promise<boolean> {
|
|
87
|
+
try {
|
|
88
|
+
const container = document.querySelector<HTMLElement>(
|
|
89
|
+
`[data-post-view][data-post-view-id="${postId}"]`,
|
|
90
|
+
);
|
|
91
|
+
if (!container) return false;
|
|
92
|
+
|
|
93
|
+
const html = await fetchPartialHtml(
|
|
94
|
+
`/_/post-view/${encodeURIComponent(postId)}`,
|
|
95
|
+
);
|
|
96
|
+
if (!html) return false;
|
|
97
|
+
|
|
98
|
+
container.outerHTML = html;
|
|
99
|
+
// outerHTML detaches `container`; re-query the replacement to hydrate it
|
|
100
|
+
// (see refreshTimelineThreadView).
|
|
101
|
+
const next = document.querySelector<HTMLElement>(
|
|
102
|
+
`[data-post-view][data-post-view-id="${postId}"]`,
|
|
103
|
+
);
|
|
104
|
+
if (next) hydratePartial(next);
|
|
105
|
+
return true;
|
|
106
|
+
} catch {
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Re-renders whichever surface an article belongs to, reusing the same
|
|
113
|
+
* targeting the reply flow uses.
|
|
114
|
+
*
|
|
115
|
+
* @param article - The rendered `article[data-post]` element to refresh
|
|
116
|
+
* @returns Whether a surface was found and re-rendered
|
|
117
|
+
* @example
|
|
118
|
+
* ```ts
|
|
119
|
+
* if (!(await refreshArticleView(article))) globalThis.location.reload();
|
|
120
|
+
* ```
|
|
121
|
+
*/
|
|
122
|
+
export async function refreshArticleView(
|
|
123
|
+
article: HTMLElement,
|
|
124
|
+
): Promise<boolean> {
|
|
125
|
+
const target = getReplyRefreshTarget(article);
|
|
126
|
+
if (!target) return false;
|
|
127
|
+
|
|
128
|
+
if (target.kind === "timeline-item") {
|
|
129
|
+
return refreshTimelineThreadView(target.id);
|
|
130
|
+
}
|
|
131
|
+
if (target.kind === "post-view") {
|
|
132
|
+
return refreshPostPageView(target.id);
|
|
133
|
+
}
|
|
134
|
+
return refreshPostCardView(target.id);
|
|
135
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
4
|
+
import { Editor } from "@tiptap/core";
|
|
5
|
+
import { createMarkdownContentExtensions } from "../../../lib/markdown-manager.js";
|
|
6
|
+
import { LinkInputRules } from "../link-input-rules.js";
|
|
7
|
+
|
|
8
|
+
const editors: Editor[] = [];
|
|
9
|
+
|
|
10
|
+
function createEditor(): Editor {
|
|
11
|
+
const element = document.createElement("div");
|
|
12
|
+
document.body.appendChild(element);
|
|
13
|
+
|
|
14
|
+
const editor = new Editor({
|
|
15
|
+
element,
|
|
16
|
+
extensions: [...createMarkdownContentExtensions(), LinkInputRules],
|
|
17
|
+
content: "<p></p>",
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
editors.push(editor);
|
|
21
|
+
return editor;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function type(editor: Editor, text: string): void {
|
|
25
|
+
const view = editor.view;
|
|
26
|
+
for (const character of text) {
|
|
27
|
+
const { from, to } = view.state.selection;
|
|
28
|
+
const handled = view.someProp("handleTextInput", (handler) =>
|
|
29
|
+
handler(view, from, to, character, () =>
|
|
30
|
+
view.state.tr.insertText(character, from, to),
|
|
31
|
+
),
|
|
32
|
+
);
|
|
33
|
+
if (!handled) {
|
|
34
|
+
view.dispatch(view.state.tr.insertText(character, from, to));
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
afterEach(() => {
|
|
40
|
+
while (editors.length > 0) {
|
|
41
|
+
editors.pop()?.destroy();
|
|
42
|
+
}
|
|
43
|
+
document.body.innerHTML = "";
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe("LinkInputRules", () => {
|
|
47
|
+
it("converts a typed sms Markdown link into a link mark", () => {
|
|
48
|
+
const editor = createEditor();
|
|
49
|
+
|
|
50
|
+
type(editor, "[xxx](sms:xxxxx@xx.com)");
|
|
51
|
+
|
|
52
|
+
expect(editor.getJSON().content?.[0]).toMatchObject({
|
|
53
|
+
type: "paragraph",
|
|
54
|
+
content: [
|
|
55
|
+
{
|
|
56
|
+
type: "text",
|
|
57
|
+
text: "xxx",
|
|
58
|
+
marks: [
|
|
59
|
+
{
|
|
60
|
+
type: "link",
|
|
61
|
+
attrs: {
|
|
62
|
+
href: "sms:xxxxx@xx.com",
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
],
|
|
66
|
+
},
|
|
67
|
+
],
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it.each(["tel:+15551234567", "sms:+15551234567"])(
|
|
72
|
+
"auto-links a bare %s action URL",
|
|
73
|
+
(href) => {
|
|
74
|
+
const editor = createEditor();
|
|
75
|
+
|
|
76
|
+
type(editor, `${href} `);
|
|
77
|
+
|
|
78
|
+
expect(editor.getJSON().content?.[0]?.content?.[0]).toMatchObject({
|
|
79
|
+
type: "text",
|
|
80
|
+
text: href,
|
|
81
|
+
marks: [
|
|
82
|
+
{
|
|
83
|
+
type: "link",
|
|
84
|
+
attrs: { href },
|
|
85
|
+
},
|
|
86
|
+
],
|
|
87
|
+
});
|
|
88
|
+
},
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
it("leaves executable Markdown links as typed text", () => {
|
|
92
|
+
const editor = createEditor();
|
|
93
|
+
|
|
94
|
+
type(editor, "[unsafe](javascript:alert(1))");
|
|
95
|
+
|
|
96
|
+
expect(editor.state.doc.textContent).toBe("[unsafe](javascript:alert(1))");
|
|
97
|
+
expect(JSON.stringify(editor.getJSON())).not.toContain('"type":"link"');
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("uses the same action-link policy for TipTap link commands", () => {
|
|
101
|
+
const editor = createEditor();
|
|
102
|
+
editor.commands.insertContent("Message");
|
|
103
|
+
editor.commands.setTextSelection({ from: 1, to: 8 });
|
|
104
|
+
|
|
105
|
+
expect(editor.commands.setLink({ href: "sms:xxxxx@xx.com" })).toBe(true);
|
|
106
|
+
expect(editor.getJSON().content?.[0]?.content?.[0]).toMatchObject({
|
|
107
|
+
marks: [
|
|
108
|
+
{
|
|
109
|
+
type: "link",
|
|
110
|
+
attrs: { href: "sms:xxxxx@xx.com" },
|
|
111
|
+
},
|
|
112
|
+
],
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
editor.commands.unsetLink();
|
|
116
|
+
expect(editor.commands.setLink({ href: "javascript:alert(1)" })).toBe(
|
|
117
|
+
false,
|
|
118
|
+
);
|
|
119
|
+
expect(JSON.stringify(editor.getJSON())).not.toContain("javascript:");
|
|
120
|
+
});
|
|
121
|
+
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Extension, InputRule } from "@tiptap/core";
|
|
2
|
-
import {
|
|
2
|
+
import { sanitizeRichTextHref } from "../../lib/url.js";
|
|
3
3
|
|
|
4
4
|
// The URL group uses [^()\s] (single char) instead of [^()\s]+ to avoid
|
|
5
5
|
// catastrophic backtracking from nested quantifiers when the regex fails
|
|
@@ -15,7 +15,7 @@ const MARKDOWN_LINK_INPUT_REGEX = new RegExp(
|
|
|
15
15
|
const MARKDOWN_LINK_SPACE_REGEX = new RegExp(
|
|
16
16
|
MARKDOWN_LINK_PATTERN.source + "\\s$",
|
|
17
17
|
);
|
|
18
|
-
const BARE_URL_INPUT_REGEX = /((?:https?:\/\/|mailto:)[^\s<]+)\s
|
|
18
|
+
const BARE_URL_INPUT_REGEX = /((?:https?:\/\/|mailto:|tel:|sms:)[^\s<]+)\s$/i;
|
|
19
19
|
|
|
20
20
|
export const LinkInputRules = Extension.create({
|
|
21
21
|
name: "linkInputRules",
|
|
@@ -36,7 +36,7 @@ export const LinkInputRules = Extension.create({
|
|
|
36
36
|
trailingChar: string,
|
|
37
37
|
) {
|
|
38
38
|
const label = match[1]?.trim();
|
|
39
|
-
const href =
|
|
39
|
+
const href = sanitizeRichTextHref(match[2] ?? "");
|
|
40
40
|
|
|
41
41
|
if (!label || !href) {
|
|
42
42
|
return null;
|
|
@@ -56,7 +56,7 @@ export const LinkInputRules = Extension.create({
|
|
|
56
56
|
new InputRule({
|
|
57
57
|
find: BARE_URL_INPUT_REGEX,
|
|
58
58
|
handler: ({ state, range, match }) => {
|
|
59
|
-
const href =
|
|
59
|
+
const href = sanitizeRichTextHref(match[1] ?? "");
|
|
60
60
|
|
|
61
61
|
if (!href) {
|
|
62
62
|
return null;
|
package/src/client/toast.ts
CHANGED
|
@@ -20,8 +20,40 @@ interface QueuedToast {
|
|
|
20
20
|
|
|
21
21
|
export const QUEUED_TOAST_STORAGE_KEY = "jant.pendingToast";
|
|
22
22
|
|
|
23
|
-
|
|
23
|
+
function openModalDialogs(): HTMLDialogElement[] {
|
|
24
|
+
const open = [
|
|
25
|
+
...document.querySelectorAll<HTMLDialogElement>("dialog[open]"),
|
|
26
|
+
];
|
|
27
|
+
try {
|
|
28
|
+
const modal = open.filter((dialog) => dialog.matches(":modal"));
|
|
29
|
+
if (modal.length > 0) return modal;
|
|
30
|
+
} catch {
|
|
31
|
+
// ":modal" unsupported — treat every open dialog as potentially modal.
|
|
32
|
+
}
|
|
33
|
+
// Engines without working ":modal" (happy-dom) match nothing above; Jant
|
|
34
|
+
// only opens dialogs via showModal(), so open dialogs are the right proxy.
|
|
35
|
+
return open;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* A modal <dialog> makes everything outside its subtree inert: the popover
|
|
40
|
+
* promotion keeps toasts *painted* above the dialog, but clicks and text
|
|
41
|
+
* selection would still fall through to the dialog. Mounting the container
|
|
42
|
+
* inside the topmost open modal dialog escapes the inert tree; the close
|
|
43
|
+
* watcher moves it back out when that dialog closes.
|
|
44
|
+
*/
|
|
45
|
+
function mountOutsideInertTree(container: HTMLElement): void {
|
|
46
|
+
const dialogs = openModalDialogs();
|
|
47
|
+
// Top-layer order isn't queryable; document order matches Jant's dialog
|
|
48
|
+
// nesting (confirm dialogs are descendants of the dialog that opened them).
|
|
49
|
+
const host = dialogs.at(-1) ?? document.body;
|
|
50
|
+
if (container.parentElement !== host) host.appendChild(container);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Ensure the toast container is interactive and in the top layer (above <dialog> etc.) */
|
|
24
54
|
function ensureTopLayer(container: HTMLElement): void {
|
|
55
|
+
mountOutsideInertTree(container);
|
|
56
|
+
|
|
25
57
|
if (typeof container.showPopover !== "function") return;
|
|
26
58
|
|
|
27
59
|
// Re-promote above any modal dialog that was opened after the toast container.
|
|
@@ -38,7 +70,21 @@ function ensureTopLayer(container: HTMLElement): void {
|
|
|
38
70
|
}
|
|
39
71
|
|
|
40
72
|
function getToastContainer(): HTMLElement | null {
|
|
41
|
-
|
|
73
|
+
const existing = document.getElementById("toast-container");
|
|
74
|
+
if (existing) return existing;
|
|
75
|
+
if (!document.body) return null;
|
|
76
|
+
|
|
77
|
+
// The container can vanish with a modal dialog it was mounted into if that
|
|
78
|
+
// dialog is removed from the DOM while open. Recreate it so toasts keep
|
|
79
|
+
// working for the rest of the page's lifetime.
|
|
80
|
+
const container = document.createElement("div");
|
|
81
|
+
container.id = "toast-container";
|
|
82
|
+
container.className = "toast-container";
|
|
83
|
+
container.setAttribute("popover", "manual");
|
|
84
|
+
container.setAttribute("aria-live", "polite");
|
|
85
|
+
container.setAttribute("aria-relevant", "additions text");
|
|
86
|
+
document.body.appendChild(container);
|
|
87
|
+
return container;
|
|
42
88
|
}
|
|
43
89
|
|
|
44
90
|
function canUseSessionStorage(): boolean {
|
|
@@ -92,12 +138,9 @@ const TOAST_ICONS = {
|
|
|
92
138
|
'<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"><circle cx="12" cy="12" r="10"/><path d="m15 9-6 6M9 9l6 6"/></svg>',
|
|
93
139
|
};
|
|
94
140
|
|
|
95
|
-
const COPY_ICON =
|
|
96
|
-
'<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>';
|
|
97
|
-
const CHECK_ICON =
|
|
98
|
-
'<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"><path d="M20 6 9 17l-5-5"/></svg>';
|
|
99
141
|
const TOAST_DURATION_MS = 3000;
|
|
100
|
-
const ACTION_TOAST_DURATION_MS =
|
|
142
|
+
const ACTION_TOAST_DURATION_MS = 5000;
|
|
143
|
+
const MIN_RESUME_MS = 1000;
|
|
101
144
|
|
|
102
145
|
/** Build toast inner content using safe DOM APIs (icon is trusted, text uses textContent). */
|
|
103
146
|
function setToastContent(
|
|
@@ -117,21 +160,62 @@ function setToastContent(
|
|
|
117
160
|
a.textContent = action.label;
|
|
118
161
|
toast.appendChild(a);
|
|
119
162
|
}
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Auto-dismiss a toast after `duration`, pausing while the pointer hovers it
|
|
167
|
+
* or focus is inside it — so the message can be read, selected, and copied.
|
|
168
|
+
* On leave, the remaining time resumes (at least MIN_RESUME_MS).
|
|
169
|
+
*/
|
|
170
|
+
function scheduleAutoDismiss(toast: HTMLElement, duration: number): void {
|
|
171
|
+
let remaining = duration;
|
|
172
|
+
let startedAt = 0;
|
|
173
|
+
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
174
|
+
let hovered = false;
|
|
175
|
+
let focused = false;
|
|
176
|
+
|
|
177
|
+
const pause = () => {
|
|
178
|
+
if (timer === null) return;
|
|
179
|
+
clearTimeout(timer);
|
|
180
|
+
timer = null;
|
|
181
|
+
remaining = Math.max(remaining - (Date.now() - startedAt), MIN_RESUME_MS);
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
const resume = () => {
|
|
185
|
+
if (timer !== null) return;
|
|
186
|
+
startedAt = Date.now();
|
|
187
|
+
timer = setTimeout(() => {
|
|
188
|
+
toast.classList.add("toast-out");
|
|
189
|
+
toast.addEventListener("animationend", () => toast.remove());
|
|
190
|
+
}, remaining);
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
const sync = () => {
|
|
194
|
+
if (hovered || focused) {
|
|
195
|
+
pause();
|
|
196
|
+
} else {
|
|
197
|
+
resume();
|
|
198
|
+
}
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
toast.addEventListener("pointerenter", () => {
|
|
202
|
+
hovered = true;
|
|
203
|
+
sync();
|
|
204
|
+
});
|
|
205
|
+
toast.addEventListener("pointerleave", () => {
|
|
206
|
+
hovered = false;
|
|
207
|
+
sync();
|
|
208
|
+
});
|
|
209
|
+
toast.addEventListener("focusin", () => {
|
|
210
|
+
focused = true;
|
|
211
|
+
sync();
|
|
212
|
+
});
|
|
213
|
+
toast.addEventListener("focusout", () => {
|
|
214
|
+
focused = false;
|
|
215
|
+
sync();
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
resume();
|
|
135
219
|
}
|
|
136
220
|
|
|
137
221
|
/**
|
|
@@ -157,10 +241,7 @@ export function showToast(message: string, type: ToastType = "success"): void {
|
|
|
157
241
|
setToastContent(toast, type, message);
|
|
158
242
|
container.appendChild(toast);
|
|
159
243
|
|
|
160
|
-
|
|
161
|
-
toast.classList.add("toast-out");
|
|
162
|
-
toast.addEventListener("animationend", () => toast.remove());
|
|
163
|
-
}, TOAST_DURATION_MS);
|
|
244
|
+
scheduleAutoDismiss(toast, TOAST_DURATION_MS);
|
|
164
245
|
}
|
|
165
246
|
|
|
166
247
|
/**
|
|
@@ -190,10 +271,7 @@ export function showToastWithAction(
|
|
|
190
271
|
setToastContent(toast, type, message, action);
|
|
191
272
|
container.appendChild(toast);
|
|
192
273
|
|
|
193
|
-
|
|
194
|
-
toast.classList.add("toast-out");
|
|
195
|
-
toast.addEventListener("animationend", () => toast.remove());
|
|
196
|
-
}, ACTION_TOAST_DURATION_MS);
|
|
274
|
+
scheduleAutoDismiss(toast, ACTION_TOAST_DURATION_MS);
|
|
197
275
|
}
|
|
198
276
|
|
|
199
277
|
/**
|
|
@@ -284,10 +362,7 @@ export function replaceWithAutoClose(
|
|
|
284
362
|
toast.replaceChildren();
|
|
285
363
|
setToastContent(toast, type, message);
|
|
286
364
|
|
|
287
|
-
|
|
288
|
-
toast.classList.add("toast-out");
|
|
289
|
-
toast.addEventListener("animationend", () => toast.remove());
|
|
290
|
-
}, TOAST_DURATION_MS);
|
|
365
|
+
scheduleAutoDismiss(toast, TOAST_DURATION_MS);
|
|
291
366
|
}
|
|
292
367
|
|
|
293
368
|
/**
|
|
@@ -317,10 +392,7 @@ export function replaceWithAutoCloseAction(
|
|
|
317
392
|
toast.replaceChildren();
|
|
318
393
|
setToastContent(toast, type, message, action);
|
|
319
394
|
|
|
320
|
-
|
|
321
|
-
toast.classList.add("toast-out");
|
|
322
|
-
toast.addEventListener("animationend", () => toast.remove());
|
|
323
|
-
}, ACTION_TOAST_DURATION_MS);
|
|
395
|
+
scheduleAutoDismiss(toast, ACTION_TOAST_DURATION_MS);
|
|
324
396
|
}
|
|
325
397
|
|
|
326
398
|
/**
|
|
@@ -363,6 +435,25 @@ export function consumeQueuedToast(): boolean {
|
|
|
363
435
|
return true;
|
|
364
436
|
}
|
|
365
437
|
|
|
438
|
+
function initDialogCloseWatcher(): void {
|
|
439
|
+
// "close" doesn't bubble; capture reaches it from the document anyway.
|
|
440
|
+
document.addEventListener(
|
|
441
|
+
"close",
|
|
442
|
+
(event) => {
|
|
443
|
+
const dialog = event.target;
|
|
444
|
+
if (!(dialog instanceof HTMLDialogElement)) return;
|
|
445
|
+
|
|
446
|
+
const container = document.getElementById("toast-container");
|
|
447
|
+
if (!container || !dialog.contains(container)) return;
|
|
448
|
+
|
|
449
|
+
// The closed dialog is display:none — move the container back out
|
|
450
|
+
// (into the next open modal dialog, if any) so live toasts stay visible.
|
|
451
|
+
ensureTopLayer(container);
|
|
452
|
+
},
|
|
453
|
+
true,
|
|
454
|
+
);
|
|
455
|
+
}
|
|
456
|
+
|
|
366
457
|
function initQueuedToastConsumer(): void {
|
|
367
458
|
const showQueuedToast = () => {
|
|
368
459
|
consumeQueuedToast();
|
|
@@ -378,4 +469,5 @@ function initQueuedToastConsumer(): void {
|
|
|
378
469
|
showQueuedToast();
|
|
379
470
|
}
|
|
380
471
|
|
|
472
|
+
initDialogCloseWatcher();
|
|
381
473
|
initQueuedToastConsumer();
|