@jant/core 0.3.36 → 0.3.37
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/export.js +1 -1
- package/bin/commands/import-site.js +529 -0
- package/bin/commands/reset-password.js +3 -2
- package/dist/client/assets/heic-to-DIRPI3VF.js +1 -0
- package/dist/client/assets/url-FWFqPJPb.js +1 -0
- package/dist/client/client.css +1 -1
- package/dist/client/client.js +4012 -3276
- package/dist/index.js +10285 -5809
- package/package.json +11 -3
- package/src/__tests__/helpers/app.ts +9 -9
- package/src/__tests__/helpers/db.ts +91 -93
- package/src/app.tsx +157 -27
- package/src/auth.ts +20 -2
- package/src/client/archive-nav.js +187 -0
- package/src/client/audio-player.ts +478 -0
- package/src/client/audio-processor.ts +84 -0
- package/src/client/avatar-upload.ts +3 -2
- package/src/client/components/__tests__/jant-compose-dialog.test.ts +645 -49
- package/src/client/components/__tests__/jant-compose-editor.test.ts +208 -16
- package/src/client/components/__tests__/jant-post-form.test.ts +19 -9
- package/src/client/components/__tests__/jant-settings-avatar.test.ts +2 -2
- package/src/client/components/__tests__/jant-settings-general.test.ts +3 -3
- package/src/client/components/collection-sidebar-types.ts +7 -9
- package/src/client/components/compose-types.ts +101 -4
- package/src/client/components/jant-collection-form.ts +43 -7
- package/src/client/components/jant-collection-sidebar.ts +88 -84
- package/src/client/components/jant-compose-dialog.ts +1655 -219
- package/src/client/components/jant-compose-editor.ts +732 -168
- package/src/client/components/jant-compose-fullscreen.ts +23 -78
- package/src/client/components/jant-media-lightbox.ts +2 -0
- package/src/client/components/jant-nav-manager.ts +24 -284
- package/src/client/components/jant-post-form.ts +89 -9
- package/src/client/components/jant-post-menu.ts +1019 -0
- package/src/client/components/jant-settings-avatar.ts +3 -3
- package/src/client/components/jant-settings-general.ts +38 -4
- package/src/client/components/jant-text-preview.ts +232 -0
- package/src/client/components/nav-manager-types.ts +4 -19
- package/src/client/components/post-form-template.ts +107 -12
- package/src/client/components/post-form-types.ts +11 -4
- package/src/client/compose-bridge.ts +410 -109
- package/src/client/image-processor.ts +26 -8
- package/src/client/media-metadata.ts +247 -0
- package/src/client/multipart-upload.ts +160 -0
- package/src/client/post-form-bridge.ts +52 -1
- package/src/client/settings-bridge.ts +0 -12
- package/src/client/thread-context.ts +140 -0
- package/src/client/tiptap/create-editor.ts +46 -0
- package/src/client/tiptap/extensions.ts +5 -0
- package/src/client/tiptap/image-node.ts +2 -8
- package/src/client/tiptap/paste-image.ts +2 -13
- package/src/client/tiptap/slash-commands.ts +173 -63
- package/src/client/toast.ts +101 -3
- package/src/client/types/sortablejs.d.ts +15 -0
- package/src/client/upload-with-metadata.ts +54 -0
- package/src/client/video-processor.ts +207 -0
- package/src/client.ts +5 -2
- package/src/db/__tests__/migrations.test.ts +118 -0
- package/src/db/index.ts +52 -0
- package/src/db/migrations/0000_baseline.sql +269 -0
- package/src/db/migrations/0001_fts_setup.sql +31 -0
- package/src/db/migrations/meta/0000_snapshot.json +703 -119
- package/src/db/migrations/meta/0001_snapshot.json +1337 -0
- package/src/db/migrations/meta/_journal.json +4 -39
- package/src/db/schema.ts +409 -145
- package/src/i18n/__tests__/detect.test.ts +115 -0
- package/src/i18n/context.tsx +2 -2
- package/src/i18n/detect.ts +85 -1
- package/src/i18n/i18n.ts +1 -1
- package/src/i18n/index.ts +2 -1
- package/src/i18n/locales/en.po +487 -1217
- package/src/i18n/locales/en.ts +1 -1
- package/src/i18n/locales/zh-Hans.po +613 -996
- package/src/i18n/locales/zh-Hans.ts +1 -1
- package/src/i18n/locales/zh-Hant.po +624 -1007
- package/src/i18n/locales/zh-Hant.ts +1 -1
- package/src/i18n/middleware.ts +6 -0
- package/src/index.ts +5 -7
- package/src/lib/__tests__/blurhash-placeholder.test.ts +75 -0
- package/src/lib/__tests__/constants.test.ts +0 -1
- package/src/lib/__tests__/markdown-to-tiptap.test.ts +358 -0
- package/src/lib/__tests__/nanoid.test.ts +26 -0
- package/src/lib/__tests__/schemas.test.ts +181 -63
- package/src/lib/__tests__/slug.test.ts +126 -0
- package/src/lib/__tests__/sse.test.ts +6 -6
- package/src/lib/__tests__/summary.test.ts +264 -0
- package/src/lib/__tests__/theme.test.ts +1 -1
- package/src/lib/__tests__/timeline.test.ts +33 -30
- package/src/lib/__tests__/tiptap-to-markdown.test.ts +346 -0
- package/src/lib/__tests__/view.test.ts +141 -66
- package/src/lib/blurhash-placeholder.ts +102 -0
- package/src/lib/constants.ts +3 -1
- package/src/lib/emoji-catalog.ts +885 -68
- package/src/lib/errors.ts +11 -8
- package/src/lib/feed.ts +78 -32
- package/src/lib/html.ts +2 -1
- package/src/lib/icon-catalog.ts +5033 -1
- package/src/lib/icons.ts +3 -2
- package/src/lib/index.ts +0 -1
- package/src/lib/markdown-to-tiptap.ts +286 -0
- package/src/lib/media-helpers.ts +12 -3
- package/src/lib/nanoid.ts +29 -0
- package/src/lib/navigation.ts +1 -1
- package/src/lib/render.tsx +20 -2
- package/src/lib/resolve-config.ts +6 -2
- package/src/lib/schemas.ts +224 -55
- package/src/lib/search-snippet.ts +34 -0
- package/src/lib/slug.ts +96 -0
- package/src/lib/sse.ts +6 -6
- package/src/lib/storage.ts +115 -7
- package/src/lib/summary.ts +66 -0
- package/src/lib/theme.ts +11 -8
- package/src/lib/timeline.ts +74 -34
- package/src/lib/tiptap-render.ts +5 -10
- package/src/lib/tiptap-to-markdown.ts +305 -0
- package/src/lib/upload.ts +190 -29
- package/src/lib/url.ts +31 -0
- package/src/lib/view.ts +204 -37
- package/src/middleware/__tests__/auth.test.ts +191 -11
- package/src/middleware/__tests__/onboarding.test.ts +12 -10
- package/src/middleware/auth.ts +63 -9
- package/src/middleware/onboarding.ts +1 -1
- package/src/middleware/secure-headers.ts +40 -0
- package/src/preset.css +45 -2
- package/src/routes/__tests__/compose.test.ts +17 -24
- package/src/routes/api/__tests__/collections.test.ts +109 -61
- package/src/routes/api/__tests__/nav-items.test.ts +46 -29
- package/src/routes/api/__tests__/posts.test.ts +132 -68
- package/src/routes/api/__tests__/search.test.ts +15 -2
- package/src/routes/api/__tests__/upload-multipart.test.ts +534 -0
- package/src/routes/api/collections.ts +51 -42
- package/src/routes/api/custom-urls.ts +80 -0
- package/src/routes/api/export.ts +31 -0
- package/src/routes/api/nav-items.ts +23 -19
- package/src/routes/api/posts.ts +43 -39
- package/src/routes/api/search.ts +3 -4
- package/src/routes/api/upload-multipart.ts +245 -0
- package/src/routes/api/upload.ts +85 -19
- package/src/routes/auth/__tests__/setup.test.ts +20 -60
- package/src/routes/auth/setup.tsx +26 -33
- package/src/routes/auth/signin.tsx +3 -7
- package/src/routes/compose.tsx +10 -55
- package/src/routes/dash/__tests__/settings-avatar.test.ts +1 -1
- package/src/routes/dash/custom-urls.tsx +414 -0
- package/src/routes/dash/settings.tsx +304 -232
- package/src/routes/feed/__tests__/rss.test.ts +27 -28
- package/src/routes/feed/rss.ts +6 -4
- package/src/routes/feed/sitemap.ts +2 -12
- package/src/routes/pages/__tests__/collections.test.ts +5 -6
- package/src/routes/pages/__tests__/featured.test.ts +41 -22
- package/src/routes/pages/archive.tsx +175 -39
- package/src/routes/pages/collection.tsx +22 -10
- package/src/routes/pages/collections.tsx +3 -3
- package/src/routes/pages/featured.tsx +28 -4
- package/src/routes/pages/home.tsx +16 -15
- package/src/routes/pages/latest.tsx +1 -11
- package/src/routes/pages/new.tsx +39 -0
- package/src/routes/pages/page.tsx +95 -49
- package/src/routes/pages/search.tsx +1 -1
- package/src/services/__tests__/api-token.test.ts +135 -0
- package/src/services/__tests__/collection.test.ts +275 -227
- package/src/services/__tests__/custom-url.test.ts +213 -0
- package/src/services/__tests__/media.test.ts +162 -22
- package/src/services/__tests__/navigation.test.ts +109 -68
- package/src/services/__tests__/post-timeline.test.ts +205 -32
- package/src/services/__tests__/post.test.ts +713 -234
- package/src/services/__tests__/search.test.ts +67 -10
- package/src/services/api-token.ts +166 -0
- package/src/services/auth.ts +17 -2
- package/src/services/collection.ts +397 -131
- package/src/services/custom-url.ts +188 -0
- package/src/services/export.ts +802 -0
- package/src/services/index.ts +26 -19
- package/src/services/media.ts +100 -22
- package/src/services/navigation.ts +158 -47
- package/src/services/path.ts +339 -0
- package/src/services/post.ts +687 -154
- package/src/services/search.ts +160 -75
- package/src/styles/components.css +58 -7
- package/src/styles/tokens.css +84 -6
- package/src/styles/ui.css +2964 -457
- package/src/types/bindings.ts +4 -1
- package/src/types/config.ts +12 -4
- package/src/types/constants.ts +15 -3
- package/src/types/entities.ts +74 -35
- package/src/types/operations.ts +11 -24
- package/src/types/props.ts +51 -16
- package/src/types/views.ts +45 -22
- package/src/ui/color-themes.ts +133 -23
- package/src/ui/compose/ComposeDialog.tsx +239 -17
- package/src/ui/compose/ComposePrompt.tsx +1 -1
- package/src/ui/dash/CrudPageHeader.tsx +1 -1
- package/src/ui/dash/ListItemRow.tsx +1 -1
- package/src/ui/dash/StatusBadge.tsx +3 -1
- package/src/ui/dash/appearance/AdvancedContent.tsx +22 -1
- package/src/ui/dash/appearance/ColorThemeContent.tsx +22 -2
- package/src/ui/dash/appearance/FontThemeContent.tsx +1 -1
- package/src/ui/dash/appearance/NavigationContent.tsx +5 -45
- package/src/ui/dash/index.ts +0 -3
- package/src/ui/dash/settings/AccountContent.tsx +3 -57
- package/src/ui/dash/settings/AccountMenuContent.tsx +147 -0
- package/src/ui/dash/settings/ApiTokensContent.tsx +232 -0
- package/src/ui/dash/settings/AvatarContent.tsx +8 -0
- package/src/ui/dash/settings/SessionsContent.tsx +159 -0
- package/src/ui/dash/settings/SettingsRootContent.tsx +45 -15
- package/src/ui/feed/LinkCard.tsx +89 -40
- package/src/ui/feed/NoteCard.tsx +39 -25
- package/src/ui/feed/PostStatusBadges.tsx +67 -0
- package/src/ui/feed/QuoteCard.tsx +38 -23
- package/src/ui/feed/ThreadPreview.tsx +90 -26
- package/src/ui/feed/TimelineFeed.tsx +3 -2
- package/src/ui/feed/TimelineItem.tsx +15 -6
- package/src/ui/feed/__tests__/thread-preview.test.ts +107 -0
- package/src/ui/feed/thread-preview-state.ts +61 -0
- package/src/ui/font-themes.ts +2 -2
- package/src/ui/layouts/BaseLayout.tsx +2 -2
- package/src/ui/layouts/SiteLayout.tsx +105 -92
- package/src/ui/pages/ArchivePage.tsx +923 -98
- package/src/ui/pages/ComposePage.tsx +54 -0
- package/src/ui/pages/PostPage.tsx +30 -45
- package/src/ui/pages/SearchPage.tsx +181 -37
- package/src/ui/shared/AdminBreadcrumb.tsx +29 -0
- package/src/ui/shared/CollectionsSidebar.tsx +47 -37
- package/src/ui/shared/MediaGallery.tsx +445 -149
- package/src/ui/shared/PostFooter.tsx +204 -0
- package/src/ui/shared/StarRating.tsx +27 -0
- package/src/ui/shared/__tests__/format-chars.test.ts +35 -0
- package/src/ui/shared/index.ts +0 -1
- package/dist/client/assets/url-8Dj-5CLW.js +0 -1
- package/src/client/media-upload.ts +0 -161
- package/src/client/page-slug-bridge.ts +0 -42
- package/src/db/migrations/0000_square_wallflower.sql +0 -118
- package/src/db/migrations/0001_add_search_fts.sql +0 -34
- package/src/db/migrations/0002_add_media_attachments.sql +0 -3
- package/src/db/migrations/0003_add_navigation_links.sql +0 -8
- package/src/db/migrations/0004_add_storage_provider.sql +0 -3
- package/src/db/migrations/0005_v2_schema_migration.sql +0 -268
- package/src/db/migrations/0006_rename_slug_to_path.sql +0 -5
- package/src/db/migrations/0007_post_collections_m2m.sql +0 -94
- package/src/db/migrations/0008_add_collection_dividers.sql +0 -8
- package/src/db/migrations/0009_drop_collection_show_divider.sql +0 -2
- package/src/db/migrations/0010_add_performance_indexes.sql +0 -16
- package/src/db/migrations/0011_add_path_registry.sql +0 -23
- package/src/db/migrations/0012_add_tiptap_columns.sql +0 -2
- package/src/db/migrations/0013_replace_featured_with_visibility.sql +0 -8
- package/src/db/migrations/meta/0003_snapshot.json +0 -821
- package/src/lib/__tests__/sqid.test.ts +0 -65
- package/src/lib/sqid.ts +0 -79
- package/src/routes/api/__tests__/pages.test.ts +0 -218
- package/src/routes/api/pages.ts +0 -73
- package/src/routes/dash/__tests__/pages.test.ts +0 -226
- package/src/routes/dash/index.tsx +0 -109
- package/src/routes/dash/media.tsx +0 -135
- package/src/routes/dash/pages.tsx +0 -245
- package/src/routes/dash/posts.tsx +0 -338
- package/src/routes/dash/redirects.tsx +0 -263
- package/src/routes/pages/post.tsx +0 -59
- package/src/services/__tests__/page.test.ts +0 -298
- package/src/services/__tests__/path-registry.test.ts +0 -165
- package/src/services/__tests__/redirect.test.ts +0 -159
- package/src/services/page.ts +0 -216
- package/src/services/path-registry.ts +0 -160
- package/src/services/redirect.ts +0 -97
- package/src/ui/dash/PageForm.tsx +0 -187
- package/src/ui/dash/PostList.tsx +0 -95
- package/src/ui/dash/media/MediaListContent.tsx +0 -206
- package/src/ui/dash/media/ViewMediaContent.tsx +0 -208
- package/src/ui/dash/pages/PagesContent.tsx +0 -75
- package/src/ui/dash/posts/PostForm.tsx +0 -260
- package/src/ui/layouts/DashLayout.tsx +0 -247
- package/src/ui/pages/SinglePage.tsx +0 -23
- package/src/ui/shared/ThreadView.tsx +0 -136
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Thread Context Interactions
|
|
3
|
+
*
|
|
4
|
+
* 1. Expand/collapse faded ancestor context via toggle button
|
|
5
|
+
* 2. Auto-scroll to current post on thread detail pages
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
function parsePixelValue(value: string, fallback: number): number {
|
|
9
|
+
const parsed = Number.parseFloat(value);
|
|
10
|
+
return Number.isFinite(parsed) ? parsed : fallback;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function getCollapsedMaxHeight(container: HTMLElement): number {
|
|
14
|
+
const value = getComputedStyle(container).getPropertyValue(
|
|
15
|
+
"--site-thread-context-max-height",
|
|
16
|
+
);
|
|
17
|
+
return parsePixelValue(value, 188);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function getPendingImages(container: HTMLElement): HTMLImageElement[] {
|
|
21
|
+
return Array.from(container.querySelectorAll("img")).filter(
|
|
22
|
+
(image) => !image.complete,
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function waitForContentToSettle(
|
|
27
|
+
container: HTMLElement,
|
|
28
|
+
callback: () => void,
|
|
29
|
+
): void {
|
|
30
|
+
const pendingImages = getPendingImages(container);
|
|
31
|
+
if (pendingImages.length === 0) {
|
|
32
|
+
requestAnimationFrame(() => {
|
|
33
|
+
requestAnimationFrame(callback);
|
|
34
|
+
});
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
let remaining = pendingImages.length;
|
|
39
|
+
const handleDone = (): void => {
|
|
40
|
+
remaining -= 1;
|
|
41
|
+
if (remaining === 0) {
|
|
42
|
+
callback();
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
pendingImages.forEach((image) => {
|
|
47
|
+
image.addEventListener("load", handleDone, { once: true });
|
|
48
|
+
image.addEventListener("error", handleDone, { once: true });
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function updateThreadContextState(
|
|
53
|
+
container: HTMLElement,
|
|
54
|
+
toggle: HTMLElement,
|
|
55
|
+
allowExpand: boolean,
|
|
56
|
+
): void {
|
|
57
|
+
const collapsedMaxHeight = getCollapsedMaxHeight(container);
|
|
58
|
+
const isExpanded = container.classList.contains("expanded");
|
|
59
|
+
const overflows = container.scrollHeight > collapsedMaxHeight + 1;
|
|
60
|
+
const showMoreLabel = toggle.dataset.labelMore ?? "Show more";
|
|
61
|
+
const showLessLabel = toggle.dataset.labelLess ?? "Show less";
|
|
62
|
+
|
|
63
|
+
if (!overflows) {
|
|
64
|
+
if (allowExpand) {
|
|
65
|
+
container.classList.remove(
|
|
66
|
+
"thread-context-collapsed",
|
|
67
|
+
"thread-context-faded",
|
|
68
|
+
"expanded",
|
|
69
|
+
);
|
|
70
|
+
} else {
|
|
71
|
+
container.classList.add("thread-context-collapsed");
|
|
72
|
+
container.classList.remove("thread-context-faded", "expanded");
|
|
73
|
+
}
|
|
74
|
+
toggle.classList.add("hidden");
|
|
75
|
+
toggle.textContent = showMoreLabel;
|
|
76
|
+
toggle.setAttribute("aria-expanded", "false");
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
container.classList.add("thread-context-collapsed");
|
|
81
|
+
container.classList.add("thread-context-faded");
|
|
82
|
+
toggle.classList.remove("hidden");
|
|
83
|
+
toggle.textContent = isExpanded ? showLessLabel : showMoreLabel;
|
|
84
|
+
toggle.setAttribute("aria-expanded", isExpanded ? "true" : "false");
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function setupThreadContext(group: HTMLElement): void {
|
|
88
|
+
const container = group.querySelector<HTMLElement>("[data-thread-context]");
|
|
89
|
+
const toggle = group.querySelector<HTMLElement>(
|
|
90
|
+
"[data-thread-context-toggle]",
|
|
91
|
+
);
|
|
92
|
+
if (!container || !toggle) return;
|
|
93
|
+
|
|
94
|
+
let allowExpand = false;
|
|
95
|
+
updateThreadContextState(container, toggle, allowExpand);
|
|
96
|
+
|
|
97
|
+
waitForContentToSettle(container, () => {
|
|
98
|
+
allowExpand = true;
|
|
99
|
+
updateThreadContextState(container, toggle, allowExpand);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
if ("ResizeObserver" in globalThis) {
|
|
103
|
+
const observer = new globalThis.ResizeObserver(() => {
|
|
104
|
+
updateThreadContextState(container, toggle, allowExpand);
|
|
105
|
+
});
|
|
106
|
+
observer.observe(container);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Expand/collapse: event delegation on toggle buttons
|
|
111
|
+
document.addEventListener("click", (e) => {
|
|
112
|
+
const toggle = (e.target as HTMLElement).closest<HTMLElement>(
|
|
113
|
+
"[data-thread-context-toggle]",
|
|
114
|
+
);
|
|
115
|
+
if (!toggle) return;
|
|
116
|
+
|
|
117
|
+
const container = toggle
|
|
118
|
+
.closest(".thread-group")
|
|
119
|
+
?.querySelector<HTMLElement>("[data-thread-context]");
|
|
120
|
+
if (!container) return;
|
|
121
|
+
|
|
122
|
+
container.classList.toggle("expanded");
|
|
123
|
+
updateThreadContextState(container, toggle, true);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
// Auto-scroll to current post on detail pages
|
|
127
|
+
document.addEventListener("DOMContentLoaded", () => {
|
|
128
|
+
document.querySelectorAll(".thread-group").forEach((group) => {
|
|
129
|
+
if (group instanceof HTMLElement) {
|
|
130
|
+
setupThreadContext(group);
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
const current = document.querySelector("[data-post-current]");
|
|
135
|
+
if (!current) return;
|
|
136
|
+
|
|
137
|
+
requestAnimationFrame(() => {
|
|
138
|
+
current.scrollIntoView({ behavior: "smooth", block: "center" });
|
|
139
|
+
});
|
|
140
|
+
});
|
|
@@ -5,7 +5,17 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { Editor, type JSONContent } from "@tiptap/core";
|
|
8
|
+
import StarterKit from "@tiptap/starter-kit";
|
|
9
|
+
import { Markdown } from "tiptap-markdown";
|
|
10
|
+
import {
|
|
11
|
+
Table,
|
|
12
|
+
TableRow,
|
|
13
|
+
TableCell,
|
|
14
|
+
TableHeader,
|
|
15
|
+
} from "@tiptap/extension-table";
|
|
8
16
|
import { createEditorExtensions } from "./extensions.js";
|
|
17
|
+
import { ImageNode } from "./image-node.js";
|
|
18
|
+
import { MoreBreak } from "./more-break.js";
|
|
9
19
|
|
|
10
20
|
export interface CreateEditorOptions {
|
|
11
21
|
element: HTMLElement;
|
|
@@ -28,6 +38,10 @@ export function createTiptapEditor(options: CreateEditorOptions): Editor {
|
|
|
28
38
|
placeholder: options.placeholder,
|
|
29
39
|
}),
|
|
30
40
|
content: options.content ?? undefined,
|
|
41
|
+
editorProps: {
|
|
42
|
+
scrollMargin: { top: 5, right: 5, bottom: 80, left: 5 },
|
|
43
|
+
scrollThreshold: { top: 5, right: 5, bottom: 80, left: 5 },
|
|
44
|
+
},
|
|
31
45
|
onUpdate: ({ editor }) => {
|
|
32
46
|
options.onUpdate?.(editor.getJSON());
|
|
33
47
|
},
|
|
@@ -38,3 +52,35 @@ export function createTiptapEditor(options: CreateEditorOptions): Editor {
|
|
|
38
52
|
|
|
39
53
|
return editor;
|
|
40
54
|
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Converts TipTap JSON content to Markdown using a headless editor.
|
|
58
|
+
*
|
|
59
|
+
* @param json - TipTap JSONContent
|
|
60
|
+
* @returns Markdown string
|
|
61
|
+
*
|
|
62
|
+
* @example
|
|
63
|
+
* const md = jsonToMarkdown({ type: "doc", content: [...] });
|
|
64
|
+
*/
|
|
65
|
+
export function jsonToMarkdown(json: JSONContent): string {
|
|
66
|
+
const editor = new Editor({
|
|
67
|
+
extensions: [
|
|
68
|
+
StarterKit.configure({
|
|
69
|
+
heading: { levels: [1, 2, 3] },
|
|
70
|
+
link: { openOnClick: false, autolink: false },
|
|
71
|
+
}),
|
|
72
|
+
Markdown,
|
|
73
|
+
Table.configure({ resizable: false }),
|
|
74
|
+
TableRow,
|
|
75
|
+
TableCell,
|
|
76
|
+
TableHeader,
|
|
77
|
+
ImageNode,
|
|
78
|
+
MoreBreak,
|
|
79
|
+
],
|
|
80
|
+
content: json,
|
|
81
|
+
});
|
|
82
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
83
|
+
const md = ((editor as any).storage.markdown.getMarkdown as () => string)();
|
|
84
|
+
editor.destroy();
|
|
85
|
+
return md;
|
|
86
|
+
}
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import type { Extensions } from "@tiptap/core";
|
|
8
8
|
import StarterKit from "@tiptap/starter-kit";
|
|
9
9
|
import Placeholder from "@tiptap/extension-placeholder";
|
|
10
|
+
import { Markdown } from "tiptap-markdown";
|
|
10
11
|
import {
|
|
11
12
|
Table,
|
|
12
13
|
TableRow,
|
|
@@ -42,6 +43,10 @@ export function createEditorExtensions(
|
|
|
42
43
|
Placeholder.configure({
|
|
43
44
|
placeholder: options.placeholder ?? "Write something…",
|
|
44
45
|
}),
|
|
46
|
+
Markdown.configure({
|
|
47
|
+
transformPastedText: true,
|
|
48
|
+
transformCopiedText: false,
|
|
49
|
+
}),
|
|
45
50
|
Table.configure({
|
|
46
51
|
resizable: false,
|
|
47
52
|
HTMLAttributes: { class: "tiptap-table" },
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
import { Node, type Editor } from "@tiptap/core";
|
|
12
12
|
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
|
|
13
13
|
import type { EditorView } from "@tiptap/pm/view";
|
|
14
|
+
import { uploadWithMetadata } from "../upload-with-metadata.js";
|
|
14
15
|
|
|
15
16
|
declare module "@tiptap/core" {
|
|
16
17
|
interface Commands<ReturnType> {
|
|
@@ -307,14 +308,7 @@ class ImageNodeView {
|
|
|
307
308
|
const file = input.files?.[0];
|
|
308
309
|
if (!file) return;
|
|
309
310
|
try {
|
|
310
|
-
const
|
|
311
|
-
formData.append("file", file);
|
|
312
|
-
const response = await fetch("/api/upload", {
|
|
313
|
-
method: "POST",
|
|
314
|
-
body: formData,
|
|
315
|
-
});
|
|
316
|
-
if (!response.ok) throw new Error(`Upload failed: ${response.status}`);
|
|
317
|
-
const data = (await response.json()) as { url: string };
|
|
311
|
+
const data = await uploadWithMetadata(file);
|
|
318
312
|
this.updateAttrs({ src: data.url });
|
|
319
313
|
} catch {
|
|
320
314
|
// Upload failed — keep current image
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
import { Extension } from "@tiptap/core";
|
|
10
10
|
import { Plugin, PluginKey } from "@tiptap/pm/state";
|
|
11
|
+
import { uploadWithMetadata } from "../upload-with-metadata.js";
|
|
11
12
|
|
|
12
13
|
const pasteImagePluginKey = new PluginKey("pasteImage");
|
|
13
14
|
|
|
@@ -81,19 +82,7 @@ async function uploadAndInsertImage(
|
|
|
81
82
|
editor.chain().focus().setImage({ src: placeholderUrl }).run();
|
|
82
83
|
|
|
83
84
|
try {
|
|
84
|
-
const
|
|
85
|
-
formData.append("file", file);
|
|
86
|
-
|
|
87
|
-
const response = await fetch("/api/upload", {
|
|
88
|
-
method: "POST",
|
|
89
|
-
body: formData,
|
|
90
|
-
});
|
|
91
|
-
|
|
92
|
-
if (!response.ok) {
|
|
93
|
-
throw new Error(`Upload failed: ${response.status}`);
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
const data = (await response.json()) as { url: string };
|
|
85
|
+
const data = await uploadWithMetadata(file);
|
|
97
86
|
|
|
98
87
|
// Replace placeholder URL with actual URL in the document
|
|
99
88
|
const { doc } = editor.state;
|
|
@@ -13,6 +13,21 @@ import Suggestion, {
|
|
|
13
13
|
} from "@tiptap/suggestion";
|
|
14
14
|
import type { Editor, Range } from "@tiptap/core";
|
|
15
15
|
|
|
16
|
+
// SVG icons (18×18, stroke-based, Lucide style)
|
|
17
|
+
const ICONS = {
|
|
18
|
+
image: `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="18" x="3" y="3" rx="2" ry="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/></svg>`,
|
|
19
|
+
divider: `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12h18"/></svg>`,
|
|
20
|
+
readMore: `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12h18"/><path d="m9 18 3 3 3-3"/><path d="m9 6-3-3-3 3"/><path d="M3 6h18"/></svg>`,
|
|
21
|
+
table: `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v18"/><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M3 9h18"/><path d="M3 15h18"/></svg>`,
|
|
22
|
+
code: `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>`,
|
|
23
|
+
blockquote: `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 21c3 0 7-1 7-8V5c0-1.25-.756-2.017-2-2H4c-1.25 0-2 .75-2 1.972V11c0 1.25.75 2 2 2 1 0 1 0 1 1v1c0 1-1 2-2 2s-1 .008-1 1.031V20c0 1 0 1 1 1z"/><path d="M15 21c3 0 7-1 7-8V5c0-1.25-.757-2.017-2-2h-4c-1.25 0-2 .75-2 1.972V11c0 1.25.75 2 2 2h.75c0 2.25.25 4-2.75 4v3c0 1 0 1 1 1z"/></svg>`,
|
|
24
|
+
bulletList: `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="8" x2="21" y1="6" y2="6"/><line x1="8" x2="21" y1="12" y2="12"/><line x1="8" x2="21" y1="18" y2="18"/><line x1="3" x2="3.01" y1="6" y2="6"/><line x1="3" x2="3.01" y1="12" y2="12"/><line x1="3" x2="3.01" y1="18" y2="18"/></svg>`,
|
|
25
|
+
orderedList: `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="10" x2="21" y1="6" y2="6"/><line x1="10" x2="21" y1="12" y2="12"/><line x1="10" x2="21" y1="18" y2="18"/><path d="M4 6h1v4"/><path d="M4 10h2"/><path d="M6 18H4c0-1 2-2 2-3s-1-1.5-2-1"/></svg>`,
|
|
26
|
+
h1: `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12h8"/><path d="M4 18V6"/><path d="M12 18V6"/><path d="M17 12l3-2v10"/></svg>`,
|
|
27
|
+
h2: `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12h8"/><path d="M4 18V6"/><path d="M12 18V6"/><path d="M21 18h-4c0-4 4-3 4-6 0-1.5-2-2.5-4-1"/></svg>`,
|
|
28
|
+
h3: `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12h8"/><path d="M4 18V6"/><path d="M12 18V6"/><path d="M17.5 10.5c1.7-1 3.5 0 3.5 1.5a2 2 0 0 1-2 2"/><path d="M17 17.5c2 1.5 4 .3 4-1.5a2 2 0 0 0-2-2"/></svg>`,
|
|
29
|
+
} as const;
|
|
30
|
+
|
|
16
31
|
interface SlashCommandItem {
|
|
17
32
|
label: string;
|
|
18
33
|
icon: string;
|
|
@@ -21,116 +36,133 @@ interface SlashCommandItem {
|
|
|
21
36
|
|
|
22
37
|
const SLASH_COMMANDS: SlashCommandItem[] = [
|
|
23
38
|
{
|
|
24
|
-
label: "
|
|
25
|
-
icon:
|
|
39
|
+
label: "Media",
|
|
40
|
+
icon: ICONS.image,
|
|
26
41
|
command: (editor, range) => {
|
|
27
|
-
editor
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
.toggleHeading({ level: 1 })
|
|
32
|
-
.run();
|
|
42
|
+
editor.chain().focus().deleteRange(range).run();
|
|
43
|
+
document.dispatchEvent(
|
|
44
|
+
new CustomEvent("jant:slash-image", { bubbles: true }),
|
|
45
|
+
);
|
|
33
46
|
},
|
|
34
47
|
},
|
|
35
48
|
{
|
|
36
|
-
label: "
|
|
37
|
-
icon:
|
|
49
|
+
label: "Divider",
|
|
50
|
+
icon: ICONS.divider,
|
|
51
|
+
command: (editor, range) => {
|
|
52
|
+
editor.chain().focus().deleteRange(range).setHorizontalRule().run();
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
label: "Read More",
|
|
57
|
+
icon: ICONS.readMore,
|
|
38
58
|
command: (editor, range) => {
|
|
39
59
|
editor
|
|
40
60
|
.chain()
|
|
41
61
|
.focus()
|
|
42
62
|
.deleteRange(range)
|
|
43
|
-
.
|
|
63
|
+
.insertContent({ type: "moreBreak" })
|
|
44
64
|
.run();
|
|
45
65
|
},
|
|
46
66
|
},
|
|
47
67
|
{
|
|
48
|
-
label: "
|
|
49
|
-
icon:
|
|
68
|
+
label: "Table",
|
|
69
|
+
icon: ICONS.table,
|
|
50
70
|
command: (editor, range) => {
|
|
51
71
|
editor
|
|
52
72
|
.chain()
|
|
53
73
|
.focus()
|
|
54
74
|
.deleteRange(range)
|
|
55
|
-
.
|
|
75
|
+
.insertTable({ rows: 3, cols: 3, withHeaderRow: true })
|
|
56
76
|
.run();
|
|
57
77
|
},
|
|
58
78
|
},
|
|
59
79
|
{
|
|
60
|
-
label: "
|
|
61
|
-
icon:
|
|
62
|
-
command: (editor, range) => {
|
|
63
|
-
editor.chain().focus().deleteRange(range).toggleBulletList().run();
|
|
64
|
-
},
|
|
65
|
-
},
|
|
66
|
-
{
|
|
67
|
-
label: "Ordered List",
|
|
68
|
-
icon: "1.",
|
|
80
|
+
label: "Code Block",
|
|
81
|
+
icon: ICONS.code,
|
|
69
82
|
command: (editor, range) => {
|
|
70
|
-
editor.chain().focus().deleteRange(range).
|
|
83
|
+
editor.chain().focus().deleteRange(range).toggleCodeBlock().run();
|
|
71
84
|
},
|
|
72
85
|
},
|
|
73
86
|
{
|
|
74
87
|
label: "Blockquote",
|
|
75
|
-
icon:
|
|
88
|
+
icon: ICONS.blockquote,
|
|
76
89
|
command: (editor, range) => {
|
|
77
90
|
editor.chain().focus().deleteRange(range).toggleBlockquote().run();
|
|
78
91
|
},
|
|
79
92
|
},
|
|
80
93
|
{
|
|
81
|
-
label: "
|
|
82
|
-
icon:
|
|
94
|
+
label: "Bullet List",
|
|
95
|
+
icon: ICONS.bulletList,
|
|
83
96
|
command: (editor, range) => {
|
|
84
|
-
editor.chain().focus().deleteRange(range).
|
|
97
|
+
editor.chain().focus().deleteRange(range).toggleBulletList().run();
|
|
85
98
|
},
|
|
86
99
|
},
|
|
87
100
|
{
|
|
88
|
-
label: "
|
|
89
|
-
icon:
|
|
101
|
+
label: "Ordered List",
|
|
102
|
+
icon: ICONS.orderedList,
|
|
90
103
|
command: (editor, range) => {
|
|
91
|
-
editor.chain().focus().deleteRange(range).
|
|
104
|
+
editor.chain().focus().deleteRange(range).toggleOrderedList().run();
|
|
92
105
|
},
|
|
93
106
|
},
|
|
94
107
|
{
|
|
95
|
-
label: "
|
|
96
|
-
icon:
|
|
108
|
+
label: "Heading 1",
|
|
109
|
+
icon: ICONS.h1,
|
|
97
110
|
command: (editor, range) => {
|
|
98
|
-
editor
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
111
|
+
editor
|
|
112
|
+
.chain()
|
|
113
|
+
.focus()
|
|
114
|
+
.deleteRange(range)
|
|
115
|
+
.toggleHeading({ level: 1 })
|
|
116
|
+
.run();
|
|
102
117
|
},
|
|
103
118
|
},
|
|
104
119
|
{
|
|
105
|
-
label: "
|
|
106
|
-
icon:
|
|
120
|
+
label: "Heading 2",
|
|
121
|
+
icon: ICONS.h2,
|
|
107
122
|
command: (editor, range) => {
|
|
108
123
|
editor
|
|
109
124
|
.chain()
|
|
110
125
|
.focus()
|
|
111
126
|
.deleteRange(range)
|
|
112
|
-
.
|
|
127
|
+
.toggleHeading({ level: 2 })
|
|
113
128
|
.run();
|
|
114
129
|
},
|
|
115
130
|
},
|
|
116
131
|
{
|
|
117
|
-
label: "
|
|
118
|
-
icon:
|
|
132
|
+
label: "Heading 3",
|
|
133
|
+
icon: ICONS.h3,
|
|
119
134
|
command: (editor, range) => {
|
|
120
135
|
editor
|
|
121
136
|
.chain()
|
|
122
137
|
.focus()
|
|
123
138
|
.deleteRange(range)
|
|
124
|
-
.
|
|
139
|
+
.toggleHeading({ level: 3 })
|
|
125
140
|
.run();
|
|
126
141
|
},
|
|
127
142
|
},
|
|
128
143
|
];
|
|
129
144
|
|
|
145
|
+
/** Check whether a document already contains a moreBreak node */
|
|
146
|
+
function hasMoreBreak(editor: Editor): boolean {
|
|
147
|
+
let found = false;
|
|
148
|
+
editor.state.doc.descendants((node) => {
|
|
149
|
+
if (node.type.name === "moreBreak") {
|
|
150
|
+
found = true;
|
|
151
|
+
return false; // stop traversal
|
|
152
|
+
}
|
|
153
|
+
return !found;
|
|
154
|
+
});
|
|
155
|
+
return found;
|
|
156
|
+
}
|
|
157
|
+
|
|
130
158
|
/**
|
|
131
159
|
* Returns the slash commands list, used by both the extension and the + menu.
|
|
160
|
+
* Omits "Read More" when the document already contains one.
|
|
132
161
|
*/
|
|
133
|
-
export function getSlashCommands(): SlashCommandItem[] {
|
|
162
|
+
export function getSlashCommands(editor?: Editor): SlashCommandItem[] {
|
|
163
|
+
if (editor && hasMoreBreak(editor)) {
|
|
164
|
+
return SLASH_COMMANDS.filter((item) => item.label !== "Read More");
|
|
165
|
+
}
|
|
134
166
|
return SLASH_COMMANDS;
|
|
135
167
|
}
|
|
136
168
|
|
|
@@ -139,6 +171,9 @@ let popupEl: HTMLElement | null = null;
|
|
|
139
171
|
let selectedIndex = 0;
|
|
140
172
|
let filteredItems: SlashCommandItem[] = [];
|
|
141
173
|
let commandFn: ((item: { index: number }) => void) | null = null;
|
|
174
|
+
let editorRef: Editor | null = null;
|
|
175
|
+
let currentRange: Range | null = null;
|
|
176
|
+
let outsideClickHandler: ((e: MouseEvent) => void) | null = null;
|
|
142
177
|
|
|
143
178
|
function createPopup(): HTMLElement {
|
|
144
179
|
const el = document.createElement("div");
|
|
@@ -147,6 +182,34 @@ function createPopup(): HTMLElement {
|
|
|
147
182
|
return el;
|
|
148
183
|
}
|
|
149
184
|
|
|
185
|
+
/** Scroll the selected item into view within the popup only (no ancestor scroll) */
|
|
186
|
+
function scrollSelectedIntoView() {
|
|
187
|
+
if (!popupEl) return;
|
|
188
|
+
const selected = popupEl.querySelector(
|
|
189
|
+
".tiptap-slash-item.is-selected",
|
|
190
|
+
) as HTMLElement | null;
|
|
191
|
+
if (!selected) return;
|
|
192
|
+
const itemTop = selected.offsetTop;
|
|
193
|
+
const itemBottom = itemTop + selected.offsetHeight;
|
|
194
|
+
const scrollTop = popupEl.scrollTop;
|
|
195
|
+
const viewBottom = scrollTop + popupEl.clientHeight;
|
|
196
|
+
if (itemTop < scrollTop) {
|
|
197
|
+
popupEl.scrollTop = itemTop;
|
|
198
|
+
} else if (itemBottom > viewBottom) {
|
|
199
|
+
popupEl.scrollTop = itemBottom - popupEl.clientHeight;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Update selection highlight and scroll into view */
|
|
204
|
+
function updateSelection() {
|
|
205
|
+
popupEl
|
|
206
|
+
?.querySelectorAll(".tiptap-slash-item")
|
|
207
|
+
.forEach((item, i) =>
|
|
208
|
+
item.classList.toggle("is-selected", i === selectedIndex),
|
|
209
|
+
);
|
|
210
|
+
scrollSelectedIntoView();
|
|
211
|
+
}
|
|
212
|
+
|
|
150
213
|
function renderPopup(
|
|
151
214
|
items: SlashCommandItem[],
|
|
152
215
|
onSelect: (index: number) => void,
|
|
@@ -175,37 +238,81 @@ function renderPopup(
|
|
|
175
238
|
});
|
|
176
239
|
el.addEventListener("mouseenter", () => {
|
|
177
240
|
selectedIndex = parseInt(el.dataset.index ?? "0", 10);
|
|
178
|
-
|
|
179
|
-
?.querySelectorAll(".tiptap-slash-item")
|
|
180
|
-
.forEach((item, i) =>
|
|
181
|
-
item.classList.toggle("is-selected", i === selectedIndex),
|
|
182
|
-
);
|
|
241
|
+
updateSelection();
|
|
183
242
|
});
|
|
184
243
|
});
|
|
185
244
|
}
|
|
186
245
|
|
|
187
246
|
function destroyPopup() {
|
|
247
|
+
if (outsideClickHandler) {
|
|
248
|
+
document.removeEventListener("mousedown", outsideClickHandler, true);
|
|
249
|
+
outsideClickHandler = null;
|
|
250
|
+
}
|
|
188
251
|
popupEl?.remove();
|
|
189
252
|
popupEl = null;
|
|
190
253
|
selectedIndex = 0;
|
|
191
254
|
filteredItems = [];
|
|
192
255
|
commandFn = null;
|
|
256
|
+
editorRef = null;
|
|
257
|
+
currentRange = null;
|
|
193
258
|
}
|
|
194
259
|
|
|
195
260
|
/**
|
|
196
261
|
* Position the popup relative to the cursor, accounting for dialog containing block.
|
|
197
262
|
* When a `<dialog>` has CSS animation, it creates a containing block that makes
|
|
198
263
|
* `position: fixed` relative to the dialog instead of the viewport.
|
|
264
|
+
* Flips above the cursor when there isn't enough space below.
|
|
199
265
|
*/
|
|
200
266
|
function positionPopup(
|
|
201
267
|
rect: globalThis.DOMRect,
|
|
202
268
|
container: HTMLElement | null,
|
|
203
269
|
) {
|
|
204
270
|
if (!popupEl) return;
|
|
271
|
+
|
|
272
|
+
// Reset inline max-height so offsetHeight reflects the natural size
|
|
273
|
+
popupEl.style.maxHeight = "";
|
|
274
|
+
|
|
205
275
|
const offsetX = container?.getBoundingClientRect().left ?? 0;
|
|
206
276
|
const offsetY = container?.getBoundingClientRect().top ?? 0;
|
|
207
|
-
|
|
208
|
-
|
|
277
|
+
const containerHeight = container?.clientHeight ?? window.innerHeight;
|
|
278
|
+
const popupHeight = popupEl.offsetHeight;
|
|
279
|
+
const gap = 4;
|
|
280
|
+
|
|
281
|
+
const left = rect.left - offsetX;
|
|
282
|
+
const belowTop = rect.bottom + gap - offsetY;
|
|
283
|
+
const spaceBelow = containerHeight - belowTop;
|
|
284
|
+
const spaceAbove = rect.top - offsetY - gap;
|
|
285
|
+
|
|
286
|
+
popupEl.style.left = `${left}px`;
|
|
287
|
+
|
|
288
|
+
if (popupHeight > spaceBelow && spaceAbove > spaceBelow) {
|
|
289
|
+
// Not enough space below and more room above — flip
|
|
290
|
+
const maxH = Math.min(popupHeight, spaceAbove);
|
|
291
|
+
if (popupHeight > spaceAbove) {
|
|
292
|
+
popupEl.style.maxHeight = `${spaceAbove}px`;
|
|
293
|
+
}
|
|
294
|
+
popupEl.style.top = `${rect.top - offsetY - maxH - gap}px`;
|
|
295
|
+
} else {
|
|
296
|
+
// Show below (constrain if needed)
|
|
297
|
+
if (popupHeight > spaceBelow) {
|
|
298
|
+
popupEl.style.maxHeight = `${spaceBelow}px`;
|
|
299
|
+
}
|
|
300
|
+
popupEl.style.top = `${belowTop}px`;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** Install a click-outside handler to dismiss the suggestion on external clicks */
|
|
305
|
+
function installClickOutside() {
|
|
306
|
+
outsideClickHandler = (e: MouseEvent) => {
|
|
307
|
+
if (!popupEl || popupEl.contains(e.target as Node)) return;
|
|
308
|
+
// Click anywhere outside the popup (including inside the editor) — dismiss
|
|
309
|
+
// by deleting the trigger text so the suggestion plugin deactivates via onExit
|
|
310
|
+
if (editorRef && currentRange) {
|
|
311
|
+
const { state, view } = editorRef;
|
|
312
|
+
view.dispatch(state.tr.delete(currentRange.from, currentRange.to));
|
|
313
|
+
}
|
|
314
|
+
};
|
|
315
|
+
document.addEventListener("mousedown", outsideClickHandler, true);
|
|
209
316
|
}
|
|
210
317
|
|
|
211
318
|
/**
|
|
@@ -219,9 +326,9 @@ export const SlashCommands = Extension.create({
|
|
|
219
326
|
suggestion: {
|
|
220
327
|
char: "/",
|
|
221
328
|
startOfLine: false,
|
|
222
|
-
items: ({ query }: { query: string }) => {
|
|
329
|
+
items: ({ query, editor }: { query: string; editor: Editor }) => {
|
|
223
330
|
const q = query.toLowerCase();
|
|
224
|
-
return
|
|
331
|
+
return getSlashCommands(editor).filter((item) =>
|
|
225
332
|
item.label.toLowerCase().includes(q),
|
|
226
333
|
);
|
|
227
334
|
},
|
|
@@ -238,6 +345,8 @@ export const SlashCommands = Extension.create({
|
|
|
238
345
|
popupEl = createPopup();
|
|
239
346
|
selectedIndex = 0;
|
|
240
347
|
commandFn = props.command;
|
|
348
|
+
editorRef = props.editor;
|
|
349
|
+
currentRange = props.range;
|
|
241
350
|
renderPopup(props.items, (index) => props.command({ index }));
|
|
242
351
|
|
|
243
352
|
// Append inside the closest dialog (top-layer) or body
|
|
@@ -249,11 +358,14 @@ export const SlashCommands = Extension.create({
|
|
|
249
358
|
if (rect) {
|
|
250
359
|
positionPopup(rect, dialog);
|
|
251
360
|
}
|
|
361
|
+
|
|
362
|
+
installClickOutside();
|
|
252
363
|
},
|
|
253
364
|
onUpdate: (
|
|
254
365
|
props: SuggestionProps<SlashCommandItem, { index: number }>,
|
|
255
366
|
) => {
|
|
256
367
|
commandFn = props.command;
|
|
368
|
+
currentRange = props.range;
|
|
257
369
|
renderPopup(props.items, (index) => props.command({ index }));
|
|
258
370
|
const rect = props.clientRect?.();
|
|
259
371
|
if (rect) {
|
|
@@ -265,30 +377,28 @@ export const SlashCommands = Extension.create({
|
|
|
265
377
|
onKeyDown: (props: SuggestionKeyDownProps) => {
|
|
266
378
|
const { event } = props;
|
|
267
379
|
if (event.key === "ArrowDown") {
|
|
380
|
+
event.preventDefault();
|
|
268
381
|
selectedIndex = (selectedIndex + 1) % filteredItems.length;
|
|
269
|
-
|
|
270
|
-
?.querySelectorAll(".tiptap-slash-item")
|
|
271
|
-
.forEach((item, i) =>
|
|
272
|
-
item.classList.toggle("is-selected", i === selectedIndex),
|
|
273
|
-
);
|
|
382
|
+
updateSelection();
|
|
274
383
|
return true;
|
|
275
384
|
}
|
|
276
385
|
if (event.key === "ArrowUp") {
|
|
386
|
+
event.preventDefault();
|
|
277
387
|
selectedIndex =
|
|
278
388
|
(selectedIndex - 1 + filteredItems.length) %
|
|
279
389
|
filteredItems.length;
|
|
280
|
-
|
|
281
|
-
?.querySelectorAll(".tiptap-slash-item")
|
|
282
|
-
.forEach((item, i) =>
|
|
283
|
-
item.classList.toggle("is-selected", i === selectedIndex),
|
|
284
|
-
);
|
|
390
|
+
updateSelection();
|
|
285
391
|
return true;
|
|
286
392
|
}
|
|
287
393
|
if (event.key === "Enter") {
|
|
394
|
+
event.preventDefault();
|
|
288
395
|
commandFn?.({ index: selectedIndex });
|
|
289
396
|
return true;
|
|
290
397
|
}
|
|
291
398
|
if (event.key === "Escape") {
|
|
399
|
+
// Stop propagation to prevent parent dialog from closing
|
|
400
|
+
event.stopPropagation();
|
|
401
|
+
event.preventDefault();
|
|
292
402
|
destroyPopup();
|
|
293
403
|
return true;
|
|
294
404
|
}
|