@jant/core 0.3.35 → 0.3.36
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/dist/client/assets/module-RjUF93sV.js +716 -0
- package/dist/client/assets/native-48B9X9Wg.js +1 -0
- package/dist/client/assets/url-8Dj-5CLW.js +1 -0
- package/dist/client/client.css +1 -1
- package/dist/client/client.js +3109 -2294
- package/dist/index.js +3026 -2778
- package/package.json +13 -4
- package/src/__tests__/helpers/app.ts +1 -1
- package/src/__tests__/helpers/db.ts +6 -0
- package/src/app.tsx +1 -5
- package/src/{lib → client}/avatar-upload.ts +1 -1
- package/src/{lib → client}/collection-form-bridge.ts +2 -2
- package/src/{ui → client}/components/__tests__/jant-collection-form.test.ts +26 -9
- package/src/{ui → client}/components/__tests__/jant-compose-dialog.test.ts +46 -14
- package/src/{ui → client}/components/__tests__/jant-compose-editor.test.ts +64 -24
- package/src/{ui → client}/components/__tests__/jant-post-form.test.ts +24 -14
- package/src/{ui → client}/components/__tests__/jant-settings-general.test.ts +3 -3
- package/src/client/components/collection-sidebar-types.ts +45 -0
- package/src/{ui → client}/components/collection-types.ts +3 -4
- package/src/{ui → client}/components/compose-types.ts +3 -1
- package/src/{ui → client}/components/jant-collection-form.ts +301 -182
- package/src/client/components/jant-collection-sidebar.ts +801 -0
- package/src/{ui → client}/components/jant-compose-dialog.ts +231 -1
- package/src/client/components/jant-compose-editor.ts +1249 -0
- package/src/client/components/jant-compose-fullscreen.ts +338 -0
- package/src/client/components/jant-media-lightbox.ts +257 -0
- package/src/{ui → client}/components/jant-nav-manager.ts +143 -84
- package/src/{ui → client}/components/jant-post-form.ts +57 -8
- package/src/{ui → client}/components/jant-settings-general.ts +2 -2
- package/src/{ui → client}/components/nav-manager-types.ts +3 -0
- package/src/{ui → client}/components/post-form-template.ts +35 -31
- package/src/{ui → client}/components/post-form-types.ts +7 -3
- package/src/{lib → client}/compose-bridge.ts +9 -7
- package/src/client/lazy-slugify.ts +51 -0
- package/src/{lib → client}/media-upload.ts +16 -3
- package/src/{lib → client}/nav-manager-bridge.ts +1 -1
- package/src/client/page-slug-bridge.ts +42 -0
- package/src/{lib → client}/post-form-bridge.ts +2 -2
- package/src/{lib → client}/settings-bridge.ts +3 -3
- package/src/client/tiptap/bubble-menu.ts +205 -0
- package/src/client/tiptap/create-editor.ts +40 -0
- package/src/client/tiptap/exitable-marks.ts +73 -0
- package/src/client/tiptap/extensions.ts +60 -0
- package/src/client/tiptap/image-node.ts +488 -0
- package/src/client/tiptap/link-toolbar.ts +371 -0
- package/src/client/tiptap/more-break.ts +50 -0
- package/src/client/tiptap/paste-image.ts +140 -0
- package/src/client/tiptap/slash-commands.ts +328 -0
- package/src/{types → client/types}/sortablejs.d.ts +1 -1
- package/src/client.ts +24 -17
- package/src/db/migrations/0012_add_tiptap_columns.sql +2 -0
- package/src/db/migrations/0013_replace_featured_with_visibility.sql +8 -0
- package/src/db/schema.ts +6 -1
- package/src/i18n/locales/en.po +641 -215
- package/src/i18n/locales/en.ts +1 -1
- package/src/i18n/locales/zh-Hans.po +642 -204
- package/src/i18n/locales/zh-Hans.ts +1 -1
- package/src/i18n/locales/zh-Hant.po +642 -204
- package/src/i18n/locales/zh-Hant.ts +1 -1
- package/src/lib/__tests__/resolve-config.test.ts +2 -2
- package/src/lib/__tests__/schemas.test.ts +9 -6
- package/src/lib/__tests__/url.test.ts +2 -2
- package/src/lib/__tests__/view.test.ts +9 -9
- package/src/lib/emoji-catalog.ts +146 -0
- package/src/lib/feed.ts +1 -1
- package/src/lib/media-helpers.ts +10 -9
- package/src/lib/render.tsx +4 -3
- package/src/lib/resolve-config.ts +8 -1
- package/src/lib/schemas.ts +2 -3
- package/src/lib/summary.ts +92 -0
- package/src/lib/timeline.ts +2 -0
- package/src/lib/tiptap-render.ts +196 -0
- package/src/lib/upload.ts +97 -9
- package/src/lib/url.ts +7 -23
- package/src/lib/view.ts +33 -19
- package/src/middleware/error-handler.ts +3 -3
- package/src/preset.css +38 -0
- package/src/routes/api/collections.ts +20 -3
- package/src/routes/api/posts.ts +48 -33
- package/src/routes/api/upload.ts +7 -5
- package/src/routes/auth/reset.tsx +5 -4
- package/src/routes/auth/setup.tsx +26 -11
- package/src/routes/auth/signin.tsx +10 -7
- package/src/routes/compose.tsx +20 -11
- package/src/routes/dash/__tests__/settings-avatar.test.ts +43 -8
- package/src/routes/dash/index.tsx +7 -1
- package/src/routes/dash/media.tsx +3 -0
- package/src/routes/dash/pages.tsx +8 -2
- package/src/routes/dash/posts.tsx +6 -2
- package/src/routes/dash/redirects.tsx +15 -9
- package/src/routes/dash/settings.tsx +336 -32
- package/src/routes/feed/__tests__/rss.test.ts +7 -7
- package/src/routes/feed/rss.ts +8 -6
- package/src/routes/pages/__tests__/featured.test.ts +6 -7
- package/src/routes/pages/archive.tsx +11 -7
- package/src/routes/pages/collection.tsx +32 -15
- package/src/routes/pages/collections.tsx +11 -2
- package/src/routes/pages/featured.tsx +1 -1
- package/src/routes/pages/home.tsx +1 -1
- package/src/services/__tests__/post.test.ts +124 -33
- package/src/services/__tests__/settings.test.ts +3 -3
- package/src/services/page.ts +16 -3
- package/src/services/post.ts +96 -37
- package/src/services/search.ts +4 -2
- package/src/services/settings.ts +6 -2
- package/src/styles/components.css +240 -60
- package/src/styles/tokens.css +10 -0
- package/src/styles/ui.css +1157 -81
- package/src/types/bindings.ts +5 -0
- package/src/types/config.ts +23 -1
- package/src/types/constants.ts +3 -0
- package/src/types/entities.ts +9 -2
- package/src/types/operations.ts +9 -3
- package/src/types/props.ts +3 -3
- package/src/types/views.ts +3 -2
- package/src/ui/compose/ComposeDialog.tsx +24 -7
- package/src/ui/dash/PageForm.tsx +2 -0
- package/src/ui/dash/PostList.tsx +5 -5
- package/src/ui/dash/StatusBadge.tsx +13 -5
- package/src/ui/dash/appearance/AdvancedContent.tsx +52 -61
- package/src/ui/dash/appearance/ColorThemeContent.tsx +30 -35
- package/src/ui/dash/appearance/FontThemeContent.tsx +65 -73
- package/src/ui/dash/appearance/NavigationContent.tsx +107 -96
- package/src/ui/dash/media/MediaListContent.tsx +9 -4
- package/src/ui/dash/media/ViewMediaContent.tsx +2 -2
- package/src/ui/dash/pages/PagesContent.tsx +2 -1
- package/src/ui/dash/posts/PostForm.tsx +19 -7
- package/src/ui/dash/settings/AccountContent.tsx +133 -138
- package/src/ui/dash/settings/AvatarContent.tsx +70 -0
- package/src/ui/dash/settings/GeneralContent.tsx +3 -62
- package/src/ui/dash/settings/SettingsRootContent.tsx +236 -0
- package/src/ui/layouts/DashLayout.tsx +157 -75
- package/src/ui/layouts/SiteLayout.tsx +13 -13
- package/src/ui/pages/ArchivePage.tsx +10 -7
- package/src/ui/pages/CollectionPage.tsx +6 -35
- package/src/ui/pages/CollectionsPage.tsx +2 -1
- package/src/ui/pages/FeaturedPage.tsx +2 -1
- package/src/ui/pages/HomePage.tsx +1 -1
- package/src/ui/pages/SearchPage.tsx +1 -1
- package/src/ui/shared/CollectionsSidebar.tsx +228 -3
- package/src/ui/shared/MediaGallery.tsx +179 -41
- package/src/lib/collections-reorder.ts +0 -28
- package/src/routes/dash/appearance.tsx +0 -240
- package/src/routes/dash/collections.tsx +0 -211
- package/src/ui/components/jant-compose-editor.ts +0 -814
- package/src/ui/dash/appearance/AppearanceNav.tsx +0 -60
- package/src/ui/dash/collections/CollectionForm.tsx +0 -166
- package/src/ui/dash/collections/CollectionsListContent.tsx +0 -146
- package/src/ui/dash/collections/IconPickerGrid.tsx +0 -50
- package/src/ui/dash/collections/ViewCollectionContent.tsx +0 -103
- package/src/ui/dash/settings/SettingsNav.tsx +0 -52
- /package/src/{ui → client}/components/__tests__/jant-settings-avatar.test.ts +0 -0
- /package/src/{ui → client}/components/jant-settings-avatar.ts +0 -0
- /package/src/{ui → client}/components/settings-types.ts +0 -0
- /package/src/{lib → client}/image-processor.ts +0 -0
- /package/src/{lib → client}/toast.ts +0 -0
|
@@ -5,10 +5,10 @@
|
|
|
5
5
|
* Manages file uploads, deferred submit flow, and toast notifications.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import type { ComposeSubmitDetail } from "
|
|
9
|
-
import type { ComposeAttachment } from "
|
|
10
|
-
import type { JantComposeDialog } from "
|
|
11
|
-
import type { JantComposeEditor } from "
|
|
8
|
+
import type { ComposeSubmitDetail } from "./components/compose-types.js";
|
|
9
|
+
import type { ComposeAttachment } from "./components/compose-types.js";
|
|
10
|
+
import type { JantComposeDialog } from "./components/jant-compose-dialog.js";
|
|
11
|
+
import type { JantComposeEditor } from "./components/jant-compose-editor.js";
|
|
12
12
|
import { ImageProcessor } from "./image-processor.js";
|
|
13
13
|
import {
|
|
14
14
|
showToast,
|
|
@@ -37,12 +37,14 @@ async function uploadFile(
|
|
|
37
37
|
// Update status to uploading
|
|
38
38
|
editor?.updateAttachmentStatus(clientId, "uploading", null, null);
|
|
39
39
|
|
|
40
|
-
// Process
|
|
41
|
-
const
|
|
40
|
+
// Process images (resize, convert to WebP); upload non-images as-is
|
|
41
|
+
const toUpload = file.type.startsWith("image/")
|
|
42
|
+
? await ImageProcessor.processToFile(file)
|
|
43
|
+
: file;
|
|
42
44
|
|
|
43
45
|
// Upload to server
|
|
44
46
|
const formData = new FormData();
|
|
45
|
-
formData.append("file",
|
|
47
|
+
formData.append("file", toUpload);
|
|
46
48
|
|
|
47
49
|
const res = await fetch("/api/upload", {
|
|
48
50
|
method: "POST",
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lazy-loaded slug generation
|
|
3
|
+
*
|
|
4
|
+
* Wraps the `slugify` function from `url.ts` behind a dynamic import so
|
|
5
|
+
* `limax` (used for i18n-aware transliteration) doesn't bloat the main
|
|
6
|
+
* client bundle. Vite code-splits it into a separate chunk.
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* ```ts
|
|
10
|
+
* import { slugify, preloadSlug } from "./lazy-slugify.js";
|
|
11
|
+
*
|
|
12
|
+
* preloadSlug(); // start loading in background
|
|
13
|
+
* const s = await slugify("你好世界"); // "ni-hao-shi-jie"
|
|
14
|
+
* ```
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
type SlugifyFn = (text: string) => string;
|
|
18
|
+
|
|
19
|
+
let slugifyFn: SlugifyFn | undefined;
|
|
20
|
+
let loadingPromise: Promise<SlugifyFn> | undefined;
|
|
21
|
+
|
|
22
|
+
function load(): Promise<SlugifyFn> {
|
|
23
|
+
if (slugifyFn) return Promise.resolve(slugifyFn);
|
|
24
|
+
if (!loadingPromise) {
|
|
25
|
+
loadingPromise = import("../lib/url.js").then((mod) => {
|
|
26
|
+
slugifyFn = mod.slugify;
|
|
27
|
+
return mod.slugify;
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
return loadingPromise;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Start loading the slug library in the background.
|
|
35
|
+
* Call this early (e.g. when a form mounts) so `slugify()` resolves instantly later.
|
|
36
|
+
*/
|
|
37
|
+
export function preloadSlug(): void {
|
|
38
|
+
load();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Generate a URL-safe slug from the given text.
|
|
43
|
+
* Handles CJK scripts via pinyin transliteration.
|
|
44
|
+
*
|
|
45
|
+
* @param text - The input string to slugify
|
|
46
|
+
* @returns A lowercased, hyphen-separated slug
|
|
47
|
+
*/
|
|
48
|
+
export async function slugify(text: string): Promise<string> {
|
|
49
|
+
const fn = await load();
|
|
50
|
+
return fn(text);
|
|
51
|
+
}
|
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import { ImageProcessor } from "./image-processor.js";
|
|
13
|
+
import { validateUploadFile } from "../lib/upload.js";
|
|
14
|
+
import { showToast } from "./toast.js";
|
|
13
15
|
|
|
14
16
|
/**
|
|
15
17
|
* Format file size for display
|
|
@@ -90,18 +92,29 @@ async function handleUpload(
|
|
|
90
92
|
input: HTMLInputElement,
|
|
91
93
|
file: File,
|
|
92
94
|
): Promise<void> {
|
|
95
|
+
const maxFileSizeMB = parseInt(input.dataset.maxFileSize || "500", 10) || 500;
|
|
93
96
|
const processingText = input.dataset.textProcessing || "Processing...";
|
|
94
97
|
const uploadingText = input.dataset.textUploading || "Uploading...";
|
|
95
98
|
const errorText =
|
|
96
99
|
input.dataset.textError || "Upload failed. Please try again.";
|
|
97
100
|
|
|
101
|
+
// Validate before creating placeholder — reject immediately with toast
|
|
102
|
+
const validationError = validateUploadFile(file, { maxFileSizeMB });
|
|
103
|
+
if (validationError) {
|
|
104
|
+
showToast(validationError, "error");
|
|
105
|
+
input.value = "";
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
|
|
98
109
|
const grid = ensureGridExists();
|
|
99
110
|
const placeholder = createPlaceholder(file.name, file.size, processingText);
|
|
100
111
|
grid.prepend(placeholder);
|
|
101
112
|
|
|
102
113
|
try {
|
|
103
|
-
// Process
|
|
104
|
-
const
|
|
114
|
+
// Process images client-side (resize, convert to WebP); upload non-images as-is
|
|
115
|
+
const toUpload = file.type.startsWith("image/")
|
|
116
|
+
? await ImageProcessor.processToFile(file)
|
|
117
|
+
: file;
|
|
105
118
|
|
|
106
119
|
// Update status
|
|
107
120
|
const statusEl = document.getElementById("upload-status");
|
|
@@ -117,7 +130,7 @@ async function handleUpload(
|
|
|
117
130
|
if (!formInput || !form) throw new Error("Upload form not found");
|
|
118
131
|
|
|
119
132
|
const dt = new DataTransfer();
|
|
120
|
-
dt.items.add(
|
|
133
|
+
dt.items.add(toUpload);
|
|
121
134
|
formInput.files = dt.files;
|
|
122
135
|
|
|
123
136
|
// Trigger Datastar-intercepted form submission
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
import type {
|
|
10
10
|
NavManagerUpdateDetail,
|
|
11
11
|
NavManagerDeleteDetail,
|
|
12
|
-
} from "
|
|
12
|
+
} from "./components/nav-manager-types.js";
|
|
13
13
|
import { showToast } from "./toast.js";
|
|
14
14
|
|
|
15
15
|
document.addEventListener("jant:nav-update", async (event: Event) => {
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Page Slug Bridge
|
|
3
|
+
*
|
|
4
|
+
* Auto-generates a slug from the page title in create mode.
|
|
5
|
+
* Listens for `input` events on the title field inside `[data-page-form]`
|
|
6
|
+
* and writes the slugified value into the slug input, dispatching an
|
|
7
|
+
* `input` event so Datastar picks up the signal change.
|
|
8
|
+
*
|
|
9
|
+
* Skipped in edit mode (detected via `data-page-edit` attribute).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { preloadSlug, slugify } from "./lazy-slugify.js";
|
|
13
|
+
|
|
14
|
+
function init() {
|
|
15
|
+
const form = document.querySelector<HTMLFormElement>("[data-page-form]");
|
|
16
|
+
if (!form || form.hasAttribute("data-page-edit")) return;
|
|
17
|
+
|
|
18
|
+
preloadSlug();
|
|
19
|
+
|
|
20
|
+
const titleInput = form.querySelector<HTMLInputElement>(
|
|
21
|
+
'[data-bind="title"]',
|
|
22
|
+
);
|
|
23
|
+
const slugInput = form.querySelector<HTMLInputElement>('[data-bind="slug"]');
|
|
24
|
+
if (!titleInput || !slugInput) return;
|
|
25
|
+
|
|
26
|
+
titleInput.addEventListener("input", () => {
|
|
27
|
+
const currentTitle = titleInput.value;
|
|
28
|
+
slugify(currentTitle).then((slug) => {
|
|
29
|
+
if (titleInput.value === currentTitle) {
|
|
30
|
+
slugInput.value = slug;
|
|
31
|
+
slugInput.dispatchEvent(new Event("input", { bubbles: true }));
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Run on DOMContentLoaded if the document isn't ready yet, otherwise run now.
|
|
38
|
+
if (document.readyState === "loading") {
|
|
39
|
+
document.addEventListener("DOMContentLoaded", init);
|
|
40
|
+
} else {
|
|
41
|
+
init();
|
|
42
|
+
}
|
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
* - `jant:post-load-media` → fetch media picker HTML and manage selections
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import type { PostSubmitDetail } from "
|
|
10
|
-
import type { JantPostForm } from "
|
|
9
|
+
import type { PostSubmitDetail } from "./components/post-form-types.js";
|
|
10
|
+
import type { JantPostForm } from "./components/jant-post-form.js";
|
|
11
11
|
import { showToast } from "./toast.js";
|
|
12
12
|
|
|
13
13
|
function findPostForm(
|
|
@@ -9,9 +9,9 @@
|
|
|
9
9
|
import type {
|
|
10
10
|
SettingsSaveDetail,
|
|
11
11
|
AvatarRemoveDetail,
|
|
12
|
-
} from "
|
|
13
|
-
import type { JantSettingsGeneral } from "
|
|
14
|
-
import type { JantSettingsAvatar } from "
|
|
12
|
+
} from "./components/settings-types.js";
|
|
13
|
+
import type { JantSettingsGeneral } from "./components/jant-settings-general.js";
|
|
14
|
+
import type { JantSettingsAvatar } from "./components/jant-settings-avatar.js";
|
|
15
15
|
import { showToast } from "./toast.js";
|
|
16
16
|
|
|
17
17
|
function updateSidebarSiteName(siteName: string) {
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bubble Menu Extension
|
|
3
|
+
*
|
|
4
|
+
* Floating toolbar that appears on text selection with inline
|
|
5
|
+
* formatting actions: Bold, Italic, H1, H2, Blockquote, Link.
|
|
6
|
+
* Vanilla DOM — positioned via ProseMirror plugin, dialog-aware.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { Extension, type Editor } from "@tiptap/core";
|
|
10
|
+
import { Plugin, PluginKey } from "@tiptap/pm/state";
|
|
11
|
+
import type { EditorView } from "@tiptap/pm/view";
|
|
12
|
+
import { isLinkToolbarInputActive } from "./link-toolbar.js";
|
|
13
|
+
|
|
14
|
+
const bubbleMenuKey = new PluginKey("bubbleMenu");
|
|
15
|
+
|
|
16
|
+
// SVG icons (16×16, stroke-based)
|
|
17
|
+
const ICONS = {
|
|
18
|
+
bold: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M6 12h9a4 4 0 0 1 0 8H6V4h8a4 4 0 0 1 0 8"/></svg>`,
|
|
19
|
+
italic: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="19" y1="4" x2="10" y2="4"/><line x1="14" y1="20" x2="5" y2="20"/><line x1="15" y1="4" x2="9" y2="20"/></svg>`,
|
|
20
|
+
h1: `<svg width="16" height="16" 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>`,
|
|
21
|
+
h2: `<svg width="16" height="16" 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>`,
|
|
22
|
+
blockquote: `<svg width="16" height="16" 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>`,
|
|
23
|
+
link: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>`,
|
|
24
|
+
} as const;
|
|
25
|
+
|
|
26
|
+
interface BubbleBtn {
|
|
27
|
+
key: string;
|
|
28
|
+
icon: string;
|
|
29
|
+
title: string;
|
|
30
|
+
action: (view: EditorView) => void;
|
|
31
|
+
isActive: (view: EditorView) => boolean;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function getButtons(editor: Editor): BubbleBtn[] {
|
|
35
|
+
return [
|
|
36
|
+
{
|
|
37
|
+
key: "bold",
|
|
38
|
+
icon: ICONS.bold,
|
|
39
|
+
title: "Bold",
|
|
40
|
+
action: () => editor.chain().focus().toggleBold().run(),
|
|
41
|
+
isActive: () => editor.isActive("bold"),
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
key: "italic",
|
|
45
|
+
icon: ICONS.italic,
|
|
46
|
+
title: "Italic",
|
|
47
|
+
action: () => editor.chain().focus().toggleItalic().run(),
|
|
48
|
+
isActive: () => editor.isActive("italic"),
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
key: "h1",
|
|
52
|
+
icon: ICONS.h1,
|
|
53
|
+
title: "Heading 1",
|
|
54
|
+
action: () => editor.chain().focus().toggleHeading({ level: 1 }).run(),
|
|
55
|
+
isActive: () => editor.isActive("heading", { level: 1 }),
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
key: "h2",
|
|
59
|
+
icon: ICONS.h2,
|
|
60
|
+
title: "Heading 2",
|
|
61
|
+
action: () => editor.chain().focus().toggleHeading({ level: 2 }).run(),
|
|
62
|
+
isActive: () => editor.isActive("heading", { level: 2 }),
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
key: "sep",
|
|
66
|
+
icon: "",
|
|
67
|
+
title: "",
|
|
68
|
+
action: () => {},
|
|
69
|
+
isActive: () => false,
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
key: "blockquote",
|
|
73
|
+
icon: ICONS.blockquote,
|
|
74
|
+
title: "Quote",
|
|
75
|
+
action: () => editor.chain().focus().toggleBlockquote().run(),
|
|
76
|
+
isActive: () => editor.isActive("blockquote"),
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
key: "link",
|
|
80
|
+
icon: ICONS.link,
|
|
81
|
+
title: "Link",
|
|
82
|
+
action: (view: EditorView) => {
|
|
83
|
+
if (editor.isActive("link")) {
|
|
84
|
+
editor.chain().focus().unsetLink().run();
|
|
85
|
+
} else {
|
|
86
|
+
view.dom.dispatchEvent(new CustomEvent("tiptap:open-link-input"));
|
|
87
|
+
}
|
|
88
|
+
},
|
|
89
|
+
isActive: () => editor.isActive("link"),
|
|
90
|
+
},
|
|
91
|
+
];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export const BubbleMenu = Extension.create({
|
|
95
|
+
name: "bubbleMenu",
|
|
96
|
+
|
|
97
|
+
addProseMirrorPlugins() {
|
|
98
|
+
const editor = this.editor;
|
|
99
|
+
let el: HTMLElement | null = null;
|
|
100
|
+
let buttons: BubbleBtn[] = [];
|
|
101
|
+
const btnEls: Map<string, HTMLButtonElement> = new Map();
|
|
102
|
+
|
|
103
|
+
function create() {
|
|
104
|
+
el = document.createElement("div");
|
|
105
|
+
el.className = "tiptap-bubble-menu";
|
|
106
|
+
el.style.position = "fixed";
|
|
107
|
+
el.style.display = "none";
|
|
108
|
+
|
|
109
|
+
buttons = getButtons(editor);
|
|
110
|
+
for (const btn of buttons) {
|
|
111
|
+
if (btn.key === "sep") {
|
|
112
|
+
const sep = document.createElement("span");
|
|
113
|
+
sep.className = "tiptap-bubble-sep";
|
|
114
|
+
el.appendChild(sep);
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
const b = document.createElement("button");
|
|
118
|
+
b.type = "button";
|
|
119
|
+
b.innerHTML = btn.icon;
|
|
120
|
+
b.title = btn.title;
|
|
121
|
+
b.className = "tiptap-bubble-btn";
|
|
122
|
+
b.addEventListener("mousedown", (e) => {
|
|
123
|
+
e.preventDefault();
|
|
124
|
+
btn.action(editor.view);
|
|
125
|
+
});
|
|
126
|
+
el.appendChild(b);
|
|
127
|
+
btnEls.set(btn.key, b);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function show(view: EditorView) {
|
|
132
|
+
if (!el) return;
|
|
133
|
+
|
|
134
|
+
// Position above selection center
|
|
135
|
+
const { from, to } = view.state.selection;
|
|
136
|
+
const start = view.coordsAtPos(from);
|
|
137
|
+
const end = view.coordsAtPos(to);
|
|
138
|
+
const cx = (start.left + end.right) / 2;
|
|
139
|
+
const top = start.top;
|
|
140
|
+
|
|
141
|
+
// Dialog offset (same pattern as slash commands)
|
|
142
|
+
const dialog = view.dom.closest("dialog");
|
|
143
|
+
const offsetX = dialog?.getBoundingClientRect().left ?? 0;
|
|
144
|
+
const offsetY = dialog?.getBoundingClientRect().top ?? 0;
|
|
145
|
+
|
|
146
|
+
el.style.display = "flex";
|
|
147
|
+
// Measure width after display
|
|
148
|
+
const rect = el.getBoundingClientRect();
|
|
149
|
+
el.style.left = `${cx - rect.width / 2 - offsetX}px`;
|
|
150
|
+
el.style.top = `${top - rect.height - 8 - offsetY}px`;
|
|
151
|
+
|
|
152
|
+
syncActive();
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function hide() {
|
|
156
|
+
if (!el) return;
|
|
157
|
+
el.style.display = "none";
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function syncActive() {
|
|
161
|
+
for (const btn of buttons) {
|
|
162
|
+
if (btn.key === "sep") continue;
|
|
163
|
+
const b = btnEls.get(btn.key);
|
|
164
|
+
if (b) b.classList.toggle("is-active", btn.isActive(editor.view));
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function shouldShow(view: EditorView): boolean {
|
|
169
|
+
const { state } = view;
|
|
170
|
+
const { selection } = state;
|
|
171
|
+
const { empty } = selection;
|
|
172
|
+
// Only show for non-empty text selections (not node selections)
|
|
173
|
+
if (empty) return false;
|
|
174
|
+
if (!selection.$from.parent.isTextblock) return false;
|
|
175
|
+
// Hide when link input popup is open
|
|
176
|
+
if (isLinkToolbarInputActive()) return false;
|
|
177
|
+
return true;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return [
|
|
181
|
+
new Plugin({
|
|
182
|
+
key: bubbleMenuKey,
|
|
183
|
+
view(editorView) {
|
|
184
|
+
create();
|
|
185
|
+
const dialog = editorView.dom.closest("dialog");
|
|
186
|
+
if (el) (dialog ?? document.body).appendChild(el);
|
|
187
|
+
|
|
188
|
+
return {
|
|
189
|
+
update(view) {
|
|
190
|
+
if (shouldShow(view)) {
|
|
191
|
+
show(view);
|
|
192
|
+
} else {
|
|
193
|
+
hide();
|
|
194
|
+
}
|
|
195
|
+
},
|
|
196
|
+
destroy() {
|
|
197
|
+
el?.remove();
|
|
198
|
+
el = null;
|
|
199
|
+
},
|
|
200
|
+
};
|
|
201
|
+
},
|
|
202
|
+
}),
|
|
203
|
+
];
|
|
204
|
+
},
|
|
205
|
+
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tiptap Editor Factory
|
|
3
|
+
*
|
|
4
|
+
* Creates configured Tiptap editor instances for use in Lit components.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { Editor, type JSONContent } from "@tiptap/core";
|
|
8
|
+
import { createEditorExtensions } from "./extensions.js";
|
|
9
|
+
|
|
10
|
+
export interface CreateEditorOptions {
|
|
11
|
+
element: HTMLElement;
|
|
12
|
+
placeholder?: string;
|
|
13
|
+
content?: JSONContent | null;
|
|
14
|
+
onUpdate?: (json: JSONContent) => void;
|
|
15
|
+
onFocus?: () => void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Creates a Tiptap editor instance with the standard extension set.
|
|
20
|
+
*
|
|
21
|
+
* @param options - Editor configuration
|
|
22
|
+
* @returns Tiptap Editor instance
|
|
23
|
+
*/
|
|
24
|
+
export function createTiptapEditor(options: CreateEditorOptions): Editor {
|
|
25
|
+
const editor = new Editor({
|
|
26
|
+
element: options.element,
|
|
27
|
+
extensions: createEditorExtensions({
|
|
28
|
+
placeholder: options.placeholder,
|
|
29
|
+
}),
|
|
30
|
+
content: options.content ?? undefined,
|
|
31
|
+
onUpdate: ({ editor }) => {
|
|
32
|
+
options.onUpdate?.(editor.getJSON());
|
|
33
|
+
},
|
|
34
|
+
onFocus: () => {
|
|
35
|
+
options.onFocus?.();
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
return editor;
|
|
40
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Exitable Marks Extension
|
|
3
|
+
*
|
|
4
|
+
* Two behaviors for escaping inline marks (bold, italic, strike, code, underline):
|
|
5
|
+
*
|
|
6
|
+
* 1. ArrowRight at end of block — clears stored marks so next typed char is plain.
|
|
7
|
+
* 2. Enter on an empty block — clears stored marks on the new paragraph.
|
|
8
|
+
* (Typing bold → Enter → empty line → Enter = formatting resets.)
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { Extension } from "@tiptap/core";
|
|
12
|
+
|
|
13
|
+
const EXITABLE_MARKS = new Set([
|
|
14
|
+
"bold",
|
|
15
|
+
"italic",
|
|
16
|
+
"strike",
|
|
17
|
+
"code",
|
|
18
|
+
"underline",
|
|
19
|
+
]);
|
|
20
|
+
|
|
21
|
+
function getExitableMarks(marks: readonly import("@tiptap/pm/model").Mark[]) {
|
|
22
|
+
return marks.filter((m) => EXITABLE_MARKS.has(m.type.name));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const ExitableMarks = Extension.create({
|
|
26
|
+
name: "exitableMarks",
|
|
27
|
+
|
|
28
|
+
addKeyboardShortcuts() {
|
|
29
|
+
return {
|
|
30
|
+
ArrowRight: ({ editor }) => {
|
|
31
|
+
const { selection } = editor.state;
|
|
32
|
+
const { $from } = selection;
|
|
33
|
+
|
|
34
|
+
if (!selection.empty) return false;
|
|
35
|
+
if ($from.pos !== $from.end()) return false;
|
|
36
|
+
|
|
37
|
+
const exitables = getExitableMarks($from.marks());
|
|
38
|
+
if (!exitables.length) return false;
|
|
39
|
+
|
|
40
|
+
// Clear stored marks; return true to consume event (don't jump to next block)
|
|
41
|
+
const { tr } = editor.state;
|
|
42
|
+
for (const mark of exitables) {
|
|
43
|
+
tr.removeStoredMark(mark);
|
|
44
|
+
}
|
|
45
|
+
editor.view.dispatch(tr);
|
|
46
|
+
return true;
|
|
47
|
+
},
|
|
48
|
+
|
|
49
|
+
Enter: ({ editor }) => {
|
|
50
|
+
const { selection } = editor.state;
|
|
51
|
+
const { $from } = selection;
|
|
52
|
+
|
|
53
|
+
if (!selection.empty) return false;
|
|
54
|
+
|
|
55
|
+
// Only act on empty blocks (e.g. a blank bold line)
|
|
56
|
+
if ($from.parent.textContent.length > 0) return false;
|
|
57
|
+
|
|
58
|
+
const storedMarks = editor.state.storedMarks ?? $from.marks();
|
|
59
|
+
const exitables = getExitableMarks(storedMarks);
|
|
60
|
+
if (!exitables.length) return false;
|
|
61
|
+
|
|
62
|
+
// Let ProseMirror create the new paragraph first, then clear marks
|
|
63
|
+
requestAnimationFrame(() => {
|
|
64
|
+
const { tr } = editor.state;
|
|
65
|
+
tr.setStoredMarks([]);
|
|
66
|
+
editor.view.dispatch(tr);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
return false;
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
},
|
|
73
|
+
});
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tiptap Extension Configuration
|
|
3
|
+
*
|
|
4
|
+
* Shared extension set for all Tiptap editor instances (compose + post form).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { Extensions } from "@tiptap/core";
|
|
8
|
+
import StarterKit from "@tiptap/starter-kit";
|
|
9
|
+
import Placeholder from "@tiptap/extension-placeholder";
|
|
10
|
+
import {
|
|
11
|
+
Table,
|
|
12
|
+
TableRow,
|
|
13
|
+
TableCell,
|
|
14
|
+
TableHeader,
|
|
15
|
+
} from "@tiptap/extension-table";
|
|
16
|
+
import { ImageNode } from "./image-node.js";
|
|
17
|
+
import { MoreBreak } from "./more-break.js";
|
|
18
|
+
import { SlashCommands } from "./slash-commands.js";
|
|
19
|
+
import { PasteImage } from "./paste-image.js";
|
|
20
|
+
import { BubbleMenu } from "./bubble-menu.js";
|
|
21
|
+
import { LinkToolbar } from "./link-toolbar.js";
|
|
22
|
+
import { ExitableMarks } from "./exitable-marks.js";
|
|
23
|
+
|
|
24
|
+
export interface EditorExtensionOptions {
|
|
25
|
+
placeholder?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Creates the standard Tiptap extension array.
|
|
30
|
+
*
|
|
31
|
+
* @param options - Configuration for extensions
|
|
32
|
+
* @returns Configured extension array
|
|
33
|
+
*/
|
|
34
|
+
export function createEditorExtensions(
|
|
35
|
+
options: EditorExtensionOptions = {},
|
|
36
|
+
): Extensions {
|
|
37
|
+
return [
|
|
38
|
+
StarterKit.configure({
|
|
39
|
+
heading: { levels: [1, 2, 3] },
|
|
40
|
+
link: { openOnClick: false, autolink: false },
|
|
41
|
+
}),
|
|
42
|
+
Placeholder.configure({
|
|
43
|
+
placeholder: options.placeholder ?? "Write something…",
|
|
44
|
+
}),
|
|
45
|
+
Table.configure({
|
|
46
|
+
resizable: false,
|
|
47
|
+
HTMLAttributes: { class: "tiptap-table" },
|
|
48
|
+
}),
|
|
49
|
+
TableRow,
|
|
50
|
+
TableCell,
|
|
51
|
+
TableHeader,
|
|
52
|
+
ImageNode,
|
|
53
|
+
MoreBreak,
|
|
54
|
+
SlashCommands,
|
|
55
|
+
PasteImage,
|
|
56
|
+
BubbleMenu,
|
|
57
|
+
LinkToolbar,
|
|
58
|
+
ExitableMarks,
|
|
59
|
+
];
|
|
60
|
+
}
|