@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.
Files changed (156) hide show
  1. package/dist/client/assets/module-RjUF93sV.js +716 -0
  2. package/dist/client/assets/native-48B9X9Wg.js +1 -0
  3. package/dist/client/assets/url-8Dj-5CLW.js +1 -0
  4. package/dist/client/client.css +1 -1
  5. package/dist/client/client.js +3109 -2294
  6. package/dist/index.js +3026 -2778
  7. package/package.json +13 -4
  8. package/src/__tests__/helpers/app.ts +1 -1
  9. package/src/__tests__/helpers/db.ts +6 -0
  10. package/src/app.tsx +1 -5
  11. package/src/{lib → client}/avatar-upload.ts +1 -1
  12. package/src/{lib → client}/collection-form-bridge.ts +2 -2
  13. package/src/{ui → client}/components/__tests__/jant-collection-form.test.ts +26 -9
  14. package/src/{ui → client}/components/__tests__/jant-compose-dialog.test.ts +46 -14
  15. package/src/{ui → client}/components/__tests__/jant-compose-editor.test.ts +64 -24
  16. package/src/{ui → client}/components/__tests__/jant-post-form.test.ts +24 -14
  17. package/src/{ui → client}/components/__tests__/jant-settings-general.test.ts +3 -3
  18. package/src/client/components/collection-sidebar-types.ts +45 -0
  19. package/src/{ui → client}/components/collection-types.ts +3 -4
  20. package/src/{ui → client}/components/compose-types.ts +3 -1
  21. package/src/{ui → client}/components/jant-collection-form.ts +301 -182
  22. package/src/client/components/jant-collection-sidebar.ts +801 -0
  23. package/src/{ui → client}/components/jant-compose-dialog.ts +231 -1
  24. package/src/client/components/jant-compose-editor.ts +1249 -0
  25. package/src/client/components/jant-compose-fullscreen.ts +338 -0
  26. package/src/client/components/jant-media-lightbox.ts +257 -0
  27. package/src/{ui → client}/components/jant-nav-manager.ts +143 -84
  28. package/src/{ui → client}/components/jant-post-form.ts +57 -8
  29. package/src/{ui → client}/components/jant-settings-general.ts +2 -2
  30. package/src/{ui → client}/components/nav-manager-types.ts +3 -0
  31. package/src/{ui → client}/components/post-form-template.ts +35 -31
  32. package/src/{ui → client}/components/post-form-types.ts +7 -3
  33. package/src/{lib → client}/compose-bridge.ts +9 -7
  34. package/src/client/lazy-slugify.ts +51 -0
  35. package/src/{lib → client}/media-upload.ts +16 -3
  36. package/src/{lib → client}/nav-manager-bridge.ts +1 -1
  37. package/src/client/page-slug-bridge.ts +42 -0
  38. package/src/{lib → client}/post-form-bridge.ts +2 -2
  39. package/src/{lib → client}/settings-bridge.ts +3 -3
  40. package/src/client/tiptap/bubble-menu.ts +205 -0
  41. package/src/client/tiptap/create-editor.ts +40 -0
  42. package/src/client/tiptap/exitable-marks.ts +73 -0
  43. package/src/client/tiptap/extensions.ts +60 -0
  44. package/src/client/tiptap/image-node.ts +488 -0
  45. package/src/client/tiptap/link-toolbar.ts +371 -0
  46. package/src/client/tiptap/more-break.ts +50 -0
  47. package/src/client/tiptap/paste-image.ts +140 -0
  48. package/src/client/tiptap/slash-commands.ts +328 -0
  49. package/src/{types → client/types}/sortablejs.d.ts +1 -1
  50. package/src/client.ts +24 -17
  51. package/src/db/migrations/0012_add_tiptap_columns.sql +2 -0
  52. package/src/db/migrations/0013_replace_featured_with_visibility.sql +8 -0
  53. package/src/db/schema.ts +6 -1
  54. package/src/i18n/locales/en.po +641 -215
  55. package/src/i18n/locales/en.ts +1 -1
  56. package/src/i18n/locales/zh-Hans.po +642 -204
  57. package/src/i18n/locales/zh-Hans.ts +1 -1
  58. package/src/i18n/locales/zh-Hant.po +642 -204
  59. package/src/i18n/locales/zh-Hant.ts +1 -1
  60. package/src/lib/__tests__/resolve-config.test.ts +2 -2
  61. package/src/lib/__tests__/schemas.test.ts +9 -6
  62. package/src/lib/__tests__/url.test.ts +2 -2
  63. package/src/lib/__tests__/view.test.ts +9 -9
  64. package/src/lib/emoji-catalog.ts +146 -0
  65. package/src/lib/feed.ts +1 -1
  66. package/src/lib/media-helpers.ts +10 -9
  67. package/src/lib/render.tsx +4 -3
  68. package/src/lib/resolve-config.ts +8 -1
  69. package/src/lib/schemas.ts +2 -3
  70. package/src/lib/summary.ts +92 -0
  71. package/src/lib/timeline.ts +2 -0
  72. package/src/lib/tiptap-render.ts +196 -0
  73. package/src/lib/upload.ts +97 -9
  74. package/src/lib/url.ts +7 -23
  75. package/src/lib/view.ts +33 -19
  76. package/src/middleware/error-handler.ts +3 -3
  77. package/src/preset.css +38 -0
  78. package/src/routes/api/collections.ts +20 -3
  79. package/src/routes/api/posts.ts +48 -33
  80. package/src/routes/api/upload.ts +7 -5
  81. package/src/routes/auth/reset.tsx +5 -4
  82. package/src/routes/auth/setup.tsx +26 -11
  83. package/src/routes/auth/signin.tsx +10 -7
  84. package/src/routes/compose.tsx +20 -11
  85. package/src/routes/dash/__tests__/settings-avatar.test.ts +43 -8
  86. package/src/routes/dash/index.tsx +7 -1
  87. package/src/routes/dash/media.tsx +3 -0
  88. package/src/routes/dash/pages.tsx +8 -2
  89. package/src/routes/dash/posts.tsx +6 -2
  90. package/src/routes/dash/redirects.tsx +15 -9
  91. package/src/routes/dash/settings.tsx +336 -32
  92. package/src/routes/feed/__tests__/rss.test.ts +7 -7
  93. package/src/routes/feed/rss.ts +8 -6
  94. package/src/routes/pages/__tests__/featured.test.ts +6 -7
  95. package/src/routes/pages/archive.tsx +11 -7
  96. package/src/routes/pages/collection.tsx +32 -15
  97. package/src/routes/pages/collections.tsx +11 -2
  98. package/src/routes/pages/featured.tsx +1 -1
  99. package/src/routes/pages/home.tsx +1 -1
  100. package/src/services/__tests__/post.test.ts +124 -33
  101. package/src/services/__tests__/settings.test.ts +3 -3
  102. package/src/services/page.ts +16 -3
  103. package/src/services/post.ts +96 -37
  104. package/src/services/search.ts +4 -2
  105. package/src/services/settings.ts +6 -2
  106. package/src/styles/components.css +240 -60
  107. package/src/styles/tokens.css +10 -0
  108. package/src/styles/ui.css +1157 -81
  109. package/src/types/bindings.ts +5 -0
  110. package/src/types/config.ts +23 -1
  111. package/src/types/constants.ts +3 -0
  112. package/src/types/entities.ts +9 -2
  113. package/src/types/operations.ts +9 -3
  114. package/src/types/props.ts +3 -3
  115. package/src/types/views.ts +3 -2
  116. package/src/ui/compose/ComposeDialog.tsx +24 -7
  117. package/src/ui/dash/PageForm.tsx +2 -0
  118. package/src/ui/dash/PostList.tsx +5 -5
  119. package/src/ui/dash/StatusBadge.tsx +13 -5
  120. package/src/ui/dash/appearance/AdvancedContent.tsx +52 -61
  121. package/src/ui/dash/appearance/ColorThemeContent.tsx +30 -35
  122. package/src/ui/dash/appearance/FontThemeContent.tsx +65 -73
  123. package/src/ui/dash/appearance/NavigationContent.tsx +107 -96
  124. package/src/ui/dash/media/MediaListContent.tsx +9 -4
  125. package/src/ui/dash/media/ViewMediaContent.tsx +2 -2
  126. package/src/ui/dash/pages/PagesContent.tsx +2 -1
  127. package/src/ui/dash/posts/PostForm.tsx +19 -7
  128. package/src/ui/dash/settings/AccountContent.tsx +133 -138
  129. package/src/ui/dash/settings/AvatarContent.tsx +70 -0
  130. package/src/ui/dash/settings/GeneralContent.tsx +3 -62
  131. package/src/ui/dash/settings/SettingsRootContent.tsx +236 -0
  132. package/src/ui/layouts/DashLayout.tsx +157 -75
  133. package/src/ui/layouts/SiteLayout.tsx +13 -13
  134. package/src/ui/pages/ArchivePage.tsx +10 -7
  135. package/src/ui/pages/CollectionPage.tsx +6 -35
  136. package/src/ui/pages/CollectionsPage.tsx +2 -1
  137. package/src/ui/pages/FeaturedPage.tsx +2 -1
  138. package/src/ui/pages/HomePage.tsx +1 -1
  139. package/src/ui/pages/SearchPage.tsx +1 -1
  140. package/src/ui/shared/CollectionsSidebar.tsx +228 -3
  141. package/src/ui/shared/MediaGallery.tsx +179 -41
  142. package/src/lib/collections-reorder.ts +0 -28
  143. package/src/routes/dash/appearance.tsx +0 -240
  144. package/src/routes/dash/collections.tsx +0 -211
  145. package/src/ui/components/jant-compose-editor.ts +0 -814
  146. package/src/ui/dash/appearance/AppearanceNav.tsx +0 -60
  147. package/src/ui/dash/collections/CollectionForm.tsx +0 -166
  148. package/src/ui/dash/collections/CollectionsListContent.tsx +0 -146
  149. package/src/ui/dash/collections/IconPickerGrid.tsx +0 -50
  150. package/src/ui/dash/collections/ViewCollectionContent.tsx +0 -103
  151. package/src/ui/dash/settings/SettingsNav.tsx +0 -52
  152. /package/src/{ui → client}/components/__tests__/jant-settings-avatar.test.ts +0 -0
  153. /package/src/{ui → client}/components/jant-settings-avatar.ts +0 -0
  154. /package/src/{ui → client}/components/settings-types.ts +0 -0
  155. /package/src/{lib → client}/image-processor.ts +0 -0
  156. /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 "../ui/components/compose-types.js";
9
- import type { ComposeAttachment } from "../ui/components/compose-types.js";
10
- import type { JantComposeDialog } from "../ui/components/jant-compose-dialog.js";
11
- import type { JantComposeEditor } from "../ui/components/jant-compose-editor.js";
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 image (resize, convert to WebP)
41
- const processed = await ImageProcessor.processToFile(file);
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", processed);
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 image client-side (resize, convert to WebP)
104
- const processed = await ImageProcessor.processToFile(file);
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(processed);
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 "../ui/components/nav-manager-types.js";
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 "../ui/components/post-form-types.js";
10
- import type { JantPostForm } from "../ui/components/jant-post-form.js";
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 "../ui/components/settings-types.js";
13
- import type { JantSettingsGeneral } from "../ui/components/jant-settings-general.js";
14
- import type { JantSettingsAvatar } from "../ui/components/jant-settings-avatar.js";
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
+ }