mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-06-06 18:51:21 +02:00
feat(editor): add WYSIWYG MarkdownEditor with formatting toolbar
Generic reusable editor component for creating markdown-based Nostr events (issues, articles, etc.). Features: - MarkdownToolbar with controls for headings, bold, italic, strikethrough, code, code blocks, blockquotes, lists, links, and horizontal rules - Custom ProseMirror-to-markdown serializer handling standard markdown plus Nostr-specific nodes (mentions, custom emojis, blob attachments, event previews) - Preview toggle showing rendered markdown via MarkdownContent - Reuses existing Tiptap extensions: mentions, emojis, blob attachments, nostr event previews, paste handlers - Link support via @tiptap/extension-link - Exposes MarkdownEditorHandle with getMarkdown(), insertBlob(), etc. https://claude.ai/code/session_01TUzfLDbarxHDYQRA2fyYTr
This commit is contained in:
25
package-lock.json
generated
25
package-lock.json
generated
@@ -28,6 +28,7 @@
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@tiptap/core": "^3.15.3",
|
||||
"@tiptap/extension-link": "^3.19.0",
|
||||
"@tiptap/extension-mention": "^3.15.3",
|
||||
"@tiptap/extension-placeholder": "^3.15.3",
|
||||
"@tiptap/pm": "^3.15.3",
|
||||
@@ -5283,16 +5284,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/core": {
|
||||
"version": "3.18.0",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.18.0.tgz",
|
||||
"integrity": "sha512-Gczd4GbK1DNgy/QUPElMVozoa0GW9mW8E31VIi7Q4a9PHHz8PcrxPmuWwtJ2q0PF8MWpOSLuBXoQTWaXZRPRnQ==",
|
||||
"version": "3.19.0",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.19.0.tgz",
|
||||
"integrity": "sha512-bpqELwPW+DG8gWiD8iiFtSl4vIBooG5uVJod92Qxn3rA9nFatyXRr4kNbMJmOZ66ezUvmCjXVe/5/G4i5cyzKA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/pm": "^3.18.0"
|
||||
"@tiptap/pm": "^3.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-blockquote": {
|
||||
@@ -5488,9 +5489,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-link": {
|
||||
"version": "3.18.0",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-3.18.0.tgz",
|
||||
"integrity": "sha512-1J28C4+fKAMQi7q/UsTjAmgmKTnzjExXY98hEBneiVzFDxqF69n7+Vb7nVTNAIhmmJkZMA0DEcMhSiQC/1/u4A==",
|
||||
"version": "3.19.0",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-3.19.0.tgz",
|
||||
"integrity": "sha512-HEGDJnnCPfr7KWu7Dsq+eRRe/mBCsv6DuI+7fhOCLDJjjKzNgrX2abbo/zG3D/4lCVFaVb+qawgJubgqXR/Smw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"linkifyjs": "^4.3.2"
|
||||
@@ -5500,8 +5501,8 @@
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "^3.18.0",
|
||||
"@tiptap/pm": "^3.18.0"
|
||||
"@tiptap/core": "^3.19.0",
|
||||
"@tiptap/pm": "^3.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-list": {
|
||||
@@ -5652,9 +5653,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/pm": {
|
||||
"version": "3.18.0",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.18.0.tgz",
|
||||
"integrity": "sha512-8RoI5gW0xBVCsuxahpK8vx7onAw6k2/uR3hbGBBnH+HocDMaAZKot3nTyY546ij8ospIC1mnQ7k4BhVUZesZDQ==",
|
||||
"version": "3.19.0",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.19.0.tgz",
|
||||
"integrity": "sha512-789zcnM4a8OWzvbD2DL31d0wbSm9BVeO/R7PLQwLIGysDI3qzrcclyZ8yhqOEVuvPitRRwYLq+mY14jz7kY4cw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-changeset": "^2.3.0",
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@tiptap/core": "^3.15.3",
|
||||
"@tiptap/extension-link": "^3.19.0",
|
||||
"@tiptap/extension-mention": "^3.15.3",
|
||||
"@tiptap/extension-placeholder": "^3.15.3",
|
||||
"@tiptap/pm": "^3.15.3",
|
||||
|
||||
528
src/components/editor/MarkdownEditor.tsx
Normal file
528
src/components/editor/MarkdownEditor.tsx
Normal file
@@ -0,0 +1,528 @@
|
||||
import {
|
||||
forwardRef,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useCallback,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useEditor, EditorContent, ReactRenderer } from "@tiptap/react";
|
||||
import { Extension } from "@tiptap/core";
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
import Mention from "@tiptap/extension-mention";
|
||||
import Placeholder from "@tiptap/extension-placeholder";
|
||||
import Link from "@tiptap/extension-link";
|
||||
import type { SuggestionOptions } from "@tiptap/suggestion";
|
||||
import tippy from "tippy.js";
|
||||
import type { Instance as TippyInstance } from "tippy.js";
|
||||
import "tippy.js/dist/tippy.css";
|
||||
import {
|
||||
ProfileSuggestionList,
|
||||
type ProfileSuggestionListHandle,
|
||||
} from "./ProfileSuggestionList";
|
||||
import {
|
||||
EmojiSuggestionList,
|
||||
type EmojiSuggestionListHandle,
|
||||
} from "./EmojiSuggestionList";
|
||||
import type { ProfileSearchResult } from "@/services/profile-search";
|
||||
import type { EmojiSearchResult } from "@/services/emoji-search";
|
||||
import { nip19 } from "nostr-tools";
|
||||
import { NostrPasteHandler } from "./extensions/nostr-paste-handler";
|
||||
import { FilePasteHandler } from "./extensions/file-paste-handler";
|
||||
import { BlobAttachmentRichNode } from "./extensions/blob-attachment-rich";
|
||||
import { NostrEventPreviewRichNode } from "./extensions/nostr-event-preview-rich";
|
||||
import type { BlobAttachment, SerializedContent } from "./MentionEditor";
|
||||
import { MarkdownToolbar } from "./MarkdownToolbar";
|
||||
import { MarkdownContent } from "@/components/nostr/MarkdownContent";
|
||||
import { serializeEditorToMarkdown } from "@/lib/markdown-serializer";
|
||||
|
||||
export interface MarkdownEditorProps {
|
||||
placeholder?: string;
|
||||
onSubmit?: (markdown: string, serialized: SerializedContent) => void;
|
||||
onChange?: () => void;
|
||||
searchProfiles: (query: string) => Promise<ProfileSearchResult[]>;
|
||||
searchEmojis?: (query: string) => Promise<EmojiSearchResult[]>;
|
||||
onFilePaste?: (files: File[]) => void;
|
||||
autoFocus?: boolean;
|
||||
className?: string;
|
||||
/** Minimum editor height in pixels */
|
||||
minHeight?: number;
|
||||
/** Maximum editor height in pixels */
|
||||
maxHeight?: number;
|
||||
}
|
||||
|
||||
export interface MarkdownEditorHandle {
|
||||
focus: () => void;
|
||||
clear: () => void;
|
||||
/** Get the content serialized as a markdown string */
|
||||
getMarkdown: () => string;
|
||||
/** Get full serialized content with emoji tags, blob attachments, etc. */
|
||||
getSerializedContent: () => SerializedContent;
|
||||
isEmpty: () => boolean;
|
||||
submit: () => void;
|
||||
/** Insert text at the current cursor position */
|
||||
insertText: (text: string) => void;
|
||||
/** Insert a blob attachment with rich preview */
|
||||
insertBlob: (blob: BlobAttachment) => void;
|
||||
/** Get editor state as JSON (for persistence/drafts) */
|
||||
getJSON: () => any;
|
||||
/** Restore editor content from JSON */
|
||||
setContent: (json: any) => void;
|
||||
}
|
||||
|
||||
// Create emoji extension by extending Mention with a different name and custom node view
|
||||
const EmojiMention = Mention.extend({
|
||||
name: "emoji",
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
...this.parent?.(),
|
||||
url: {
|
||||
default: null,
|
||||
parseHTML: (element) => element.getAttribute("data-url"),
|
||||
renderHTML: (attributes) => {
|
||||
if (!attributes.url) return {};
|
||||
return { "data-url": attributes.url };
|
||||
},
|
||||
},
|
||||
source: {
|
||||
default: null,
|
||||
parseHTML: (element) => element.getAttribute("data-source"),
|
||||
renderHTML: (attributes) => {
|
||||
if (!attributes.source) return {};
|
||||
return { "data-source": attributes.source };
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
renderText({ node }) {
|
||||
if (node.attrs.source === "unicode") {
|
||||
return node.attrs.url || "";
|
||||
}
|
||||
return `:${node.attrs.id}:`;
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ({ node }) => {
|
||||
const { url, source, id } = node.attrs;
|
||||
const isUnicode = source === "unicode";
|
||||
|
||||
const dom = document.createElement("span");
|
||||
dom.className = "emoji-node";
|
||||
dom.setAttribute("data-emoji", id || "");
|
||||
|
||||
if (isUnicode && url) {
|
||||
const span = document.createElement("span");
|
||||
span.className = "emoji-unicode";
|
||||
span.textContent = url;
|
||||
span.title = `:${id}:`;
|
||||
dom.appendChild(span);
|
||||
} else if (url) {
|
||||
const img = document.createElement("img");
|
||||
img.src = url;
|
||||
img.alt = `:${id}:`;
|
||||
img.title = `:${id}:`;
|
||||
img.className = "emoji-image";
|
||||
img.draggable = false;
|
||||
img.onerror = () => {
|
||||
dom.textContent = `:${id}:`;
|
||||
};
|
||||
dom.appendChild(img);
|
||||
} else {
|
||||
dom.textContent = `:${id}:`;
|
||||
}
|
||||
|
||||
return { dom };
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const MarkdownEditor = forwardRef<
|
||||
MarkdownEditorHandle,
|
||||
MarkdownEditorProps
|
||||
>(
|
||||
(
|
||||
{
|
||||
placeholder = "Write markdown...",
|
||||
onSubmit,
|
||||
onChange,
|
||||
searchProfiles,
|
||||
searchEmojis,
|
||||
onFilePaste,
|
||||
autoFocus = false,
|
||||
className = "",
|
||||
minHeight = 200,
|
||||
maxHeight = 600,
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const [preview, setPreview] = useState(false);
|
||||
const [previewContent, setPreviewContent] = useState("");
|
||||
const handleSubmitRef = useRef<(editor: any) => void>(() => {});
|
||||
|
||||
// Create mention suggestion configuration
|
||||
const mentionSuggestion: Omit<SuggestionOptions, "editor"> = useMemo(
|
||||
() => ({
|
||||
char: "@",
|
||||
allowSpaces: false,
|
||||
items: async ({ query }) => {
|
||||
return await searchProfiles(query);
|
||||
},
|
||||
render: () => {
|
||||
let component: ReactRenderer<ProfileSuggestionListHandle>;
|
||||
let popup: TippyInstance[];
|
||||
|
||||
return {
|
||||
onStart: (props) => {
|
||||
component = new ReactRenderer(ProfileSuggestionList, {
|
||||
props: { items: [], command: props.command },
|
||||
editor: props.editor,
|
||||
});
|
||||
|
||||
if (!props.clientRect) return;
|
||||
|
||||
popup = tippy("body", {
|
||||
getReferenceClientRect: props.clientRect as () => DOMRect,
|
||||
appendTo: () => document.body,
|
||||
content: component.element,
|
||||
showOnCreate: true,
|
||||
interactive: true,
|
||||
trigger: "manual",
|
||||
placement: "bottom-start",
|
||||
theme: "mention",
|
||||
});
|
||||
},
|
||||
|
||||
onUpdate(props) {
|
||||
component.updateProps({
|
||||
items: props.items,
|
||||
command: props.command,
|
||||
});
|
||||
if (!props.clientRect) return;
|
||||
popup[0].setProps({
|
||||
getReferenceClientRect: props.clientRect as () => DOMRect,
|
||||
});
|
||||
},
|
||||
|
||||
onKeyDown(props) {
|
||||
if (props.event.key === "Escape") {
|
||||
popup[0].hide();
|
||||
return true;
|
||||
}
|
||||
return component.ref?.onKeyDown(props.event) || false;
|
||||
},
|
||||
|
||||
onExit() {
|
||||
popup[0].destroy();
|
||||
component.destroy();
|
||||
},
|
||||
};
|
||||
},
|
||||
}),
|
||||
[searchProfiles],
|
||||
);
|
||||
|
||||
// Create emoji suggestion configuration
|
||||
const emojiSuggestion: Omit<SuggestionOptions, "editor"> | undefined =
|
||||
useMemo(() => {
|
||||
if (!searchEmojis) return undefined;
|
||||
|
||||
return {
|
||||
char: ":",
|
||||
allowSpaces: false,
|
||||
items: async ({ query }) => {
|
||||
return await searchEmojis(query);
|
||||
},
|
||||
render: () => {
|
||||
let component: ReactRenderer<EmojiSuggestionListHandle>;
|
||||
let popup: TippyInstance[];
|
||||
|
||||
return {
|
||||
onStart: (props) => {
|
||||
component = new ReactRenderer(EmojiSuggestionList, {
|
||||
props: { items: [], command: props.command },
|
||||
editor: props.editor,
|
||||
});
|
||||
|
||||
if (!props.clientRect) return;
|
||||
|
||||
popup = tippy("body", {
|
||||
getReferenceClientRect: props.clientRect as () => DOMRect,
|
||||
appendTo: () => document.body,
|
||||
content: component.element,
|
||||
showOnCreate: true,
|
||||
interactive: true,
|
||||
trigger: "manual",
|
||||
placement: "bottom-start",
|
||||
theme: "mention",
|
||||
});
|
||||
},
|
||||
|
||||
onUpdate(props) {
|
||||
component.updateProps({
|
||||
items: props.items,
|
||||
command: props.command,
|
||||
});
|
||||
if (!props.clientRect) return;
|
||||
popup[0].setProps({
|
||||
getReferenceClientRect: props.clientRect as () => DOMRect,
|
||||
});
|
||||
},
|
||||
|
||||
onKeyDown(props) {
|
||||
if (props.event.key === "Escape") {
|
||||
popup[0].hide();
|
||||
return true;
|
||||
}
|
||||
return component.ref?.onKeyDown(props.event) || false;
|
||||
},
|
||||
|
||||
onExit() {
|
||||
popup[0].destroy();
|
||||
component.destroy();
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}, [searchEmojis]);
|
||||
|
||||
// Handle submit
|
||||
const handleSubmit = useCallback(
|
||||
(editorInstance: any) => {
|
||||
if (editorInstance.isEmpty) return;
|
||||
|
||||
const serialized = serializeEditorToMarkdown(editorInstance);
|
||||
|
||||
if (onSubmit) {
|
||||
onSubmit(serialized.text, serialized);
|
||||
}
|
||||
},
|
||||
[onSubmit],
|
||||
);
|
||||
|
||||
handleSubmitRef.current = handleSubmit;
|
||||
|
||||
// Build extensions
|
||||
const extensions = useMemo(() => {
|
||||
const SubmitShortcut = Extension.create({
|
||||
name: "submitShortcut",
|
||||
addKeyboardShortcuts() {
|
||||
return {
|
||||
"Mod-Enter": ({ editor }) => {
|
||||
handleSubmitRef.current(editor);
|
||||
return true;
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const exts = [
|
||||
SubmitShortcut,
|
||||
StarterKit.configure({
|
||||
hardBreak: { keepMarks: false },
|
||||
}),
|
||||
Link.configure({
|
||||
openOnClick: false,
|
||||
HTMLAttributes: {
|
||||
class: "text-accent underline decoration-dotted cursor-pointer",
|
||||
},
|
||||
}),
|
||||
Mention.extend({
|
||||
renderText({ node }) {
|
||||
try {
|
||||
return `nostr:${nip19.npubEncode(node.attrs.id)}`;
|
||||
} catch {
|
||||
return `@${node.attrs.label}`;
|
||||
}
|
||||
},
|
||||
}).configure({
|
||||
HTMLAttributes: { class: "mention" },
|
||||
suggestion: {
|
||||
...mentionSuggestion,
|
||||
command: ({ editor, range, props }: any) => {
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.insertContentAt(range, [
|
||||
{
|
||||
type: "mention",
|
||||
attrs: {
|
||||
id: props.pubkey,
|
||||
label: props.displayName,
|
||||
},
|
||||
},
|
||||
{ type: "text", text: " " },
|
||||
])
|
||||
.run();
|
||||
},
|
||||
},
|
||||
renderLabel({ node }) {
|
||||
return `@${node.attrs.label}`;
|
||||
},
|
||||
}),
|
||||
Placeholder.configure({ placeholder }),
|
||||
BlobAttachmentRichNode,
|
||||
NostrEventPreviewRichNode,
|
||||
NostrPasteHandler,
|
||||
FilePasteHandler.configure({ onFilePaste }),
|
||||
];
|
||||
|
||||
if (emojiSuggestion) {
|
||||
exts.push(
|
||||
EmojiMention.configure({
|
||||
HTMLAttributes: { class: "emoji" },
|
||||
suggestion: {
|
||||
...emojiSuggestion,
|
||||
command: ({ editor, range, props }: any) => {
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.insertContentAt(range, [
|
||||
{
|
||||
type: "emoji",
|
||||
attrs: {
|
||||
id: props.shortcode,
|
||||
label: props.shortcode,
|
||||
url: props.url,
|
||||
source: props.source,
|
||||
},
|
||||
},
|
||||
{ type: "text", text: " " },
|
||||
])
|
||||
.run();
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return exts;
|
||||
}, [mentionSuggestion, emojiSuggestion, onFilePaste, placeholder]);
|
||||
|
||||
const editor = useEditor({
|
||||
extensions,
|
||||
editorProps: {
|
||||
attributes: {
|
||||
class: "prose prose-sm max-w-none focus:outline-none",
|
||||
style: `min-height: ${minHeight}px; max-height: ${maxHeight}px; overflow-y: auto; padding: 1rem;`,
|
||||
},
|
||||
},
|
||||
autofocus: autoFocus,
|
||||
onUpdate: () => {
|
||||
onChange?.();
|
||||
},
|
||||
});
|
||||
|
||||
const isEditorReady = useCallback(() => {
|
||||
return editor && editor.view && editor.view.dom;
|
||||
}, [editor]);
|
||||
|
||||
// Toggle preview mode
|
||||
const togglePreview = useCallback(() => {
|
||||
setPreview((prev) => {
|
||||
if (!prev && isEditorReady() && editor) {
|
||||
// Entering preview: capture current markdown
|
||||
const serialized = serializeEditorToMarkdown(editor);
|
||||
setPreviewContent(serialized.text);
|
||||
}
|
||||
return !prev;
|
||||
});
|
||||
}, [editor, isEditorReady]);
|
||||
|
||||
// Expose editor methods
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
focus: () => {
|
||||
if (isEditorReady()) {
|
||||
editor?.commands.focus();
|
||||
}
|
||||
},
|
||||
clear: () => {
|
||||
if (isEditorReady()) {
|
||||
editor?.commands.clearContent();
|
||||
}
|
||||
},
|
||||
getMarkdown: () => {
|
||||
if (!isEditorReady() || !editor) return "";
|
||||
return serializeEditorToMarkdown(editor).text;
|
||||
},
|
||||
getSerializedContent: () => {
|
||||
if (!isEditorReady() || !editor)
|
||||
return {
|
||||
text: "",
|
||||
emojiTags: [],
|
||||
blobAttachments: [],
|
||||
addressRefs: [],
|
||||
};
|
||||
return serializeEditorToMarkdown(editor);
|
||||
},
|
||||
isEmpty: () => {
|
||||
if (!isEditorReady()) return true;
|
||||
return editor?.isEmpty ?? true;
|
||||
},
|
||||
submit: () => {
|
||||
if (isEditorReady() && editor) {
|
||||
handleSubmit(editor);
|
||||
}
|
||||
},
|
||||
insertText: (text: string) => {
|
||||
if (isEditorReady()) {
|
||||
editor?.commands.insertContent(text);
|
||||
}
|
||||
},
|
||||
insertBlob: (blob: BlobAttachment) => {
|
||||
if (isEditorReady()) {
|
||||
editor?.commands.insertContent({
|
||||
type: "blobAttachment",
|
||||
attrs: blob,
|
||||
});
|
||||
}
|
||||
},
|
||||
getJSON: () => {
|
||||
if (!isEditorReady()) return null;
|
||||
return editor?.getJSON() || null;
|
||||
},
|
||||
setContent: (json: any) => {
|
||||
if (isEditorReady() && json) {
|
||||
editor?.commands.setContent(json);
|
||||
}
|
||||
},
|
||||
}),
|
||||
[editor, handleSubmit, isEditorReady],
|
||||
);
|
||||
|
||||
if (!editor) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`markdown-editor flex flex-col border border-border rounded overflow-hidden ${className}`}
|
||||
>
|
||||
<MarkdownToolbar
|
||||
editor={editor}
|
||||
preview={preview}
|
||||
onTogglePreview={togglePreview}
|
||||
/>
|
||||
|
||||
{preview ? (
|
||||
<div className="p-4 overflow-y-auto" style={{ minHeight, maxHeight }}>
|
||||
{previewContent ? (
|
||||
<MarkdownContent content={previewContent} />
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground italic">
|
||||
Nothing to preview
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<EditorContent editor={editor} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
MarkdownEditor.displayName = "MarkdownEditor";
|
||||
323
src/components/editor/MarkdownToolbar.tsx
Normal file
323
src/components/editor/MarkdownToolbar.tsx
Normal file
@@ -0,0 +1,323 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import type { Editor } from "@tiptap/core";
|
||||
import {
|
||||
Bold,
|
||||
Italic,
|
||||
Strikethrough,
|
||||
Code,
|
||||
Heading1,
|
||||
Heading2,
|
||||
Heading3,
|
||||
List,
|
||||
ListOrdered,
|
||||
Quote,
|
||||
CodeSquare,
|
||||
Link,
|
||||
Unlink,
|
||||
Minus,
|
||||
Eye,
|
||||
Pencil,
|
||||
} from "lucide-react";
|
||||
|
||||
interface MarkdownToolbarProps {
|
||||
editor: Editor | null;
|
||||
preview: boolean;
|
||||
onTogglePreview: () => void;
|
||||
}
|
||||
|
||||
interface ToolbarButtonProps {
|
||||
onClick: () => void;
|
||||
active?: boolean;
|
||||
disabled?: boolean;
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
function ToolbarButton({
|
||||
onClick,
|
||||
active = false,
|
||||
disabled = false,
|
||||
title,
|
||||
children,
|
||||
}: ToolbarButtonProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={(e) => {
|
||||
// Prevent stealing focus from editor
|
||||
e.preventDefault();
|
||||
onClick();
|
||||
}}
|
||||
disabled={disabled}
|
||||
title={title}
|
||||
className={`p-1.5 rounded transition-colors ${
|
||||
active
|
||||
? "bg-primary/20 text-primary"
|
||||
: disabled
|
||||
? "text-muted-foreground/40 cursor-not-allowed"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolbarSeparator() {
|
||||
return <div className="w-px h-5 bg-border mx-0.5" />;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formatting toolbar for the MarkdownEditor.
|
||||
* Provides buttons for common markdown formatting, link insertion,
|
||||
* and a preview toggle.
|
||||
*/
|
||||
export function MarkdownToolbar({
|
||||
editor,
|
||||
preview,
|
||||
onTogglePreview,
|
||||
}: MarkdownToolbarProps) {
|
||||
const [linkInput, setLinkInput] = useState<{
|
||||
open: boolean;
|
||||
url: string;
|
||||
}>({ open: false, url: "" });
|
||||
|
||||
const isActive = useCallback(
|
||||
(name: string, attrs?: Record<string, any>) => {
|
||||
if (!editor) return false;
|
||||
return editor.isActive(name, attrs);
|
||||
},
|
||||
[editor],
|
||||
);
|
||||
|
||||
const run = useCallback(
|
||||
(command: (chain: any) => any) => {
|
||||
if (!editor) return;
|
||||
command(editor.chain().focus());
|
||||
},
|
||||
[editor],
|
||||
);
|
||||
|
||||
const handleLinkSubmit = useCallback(() => {
|
||||
if (!editor || !linkInput.url) {
|
||||
setLinkInput({ open: false, url: "" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Ensure URL has a protocol
|
||||
let url = linkInput.url.trim();
|
||||
if (url && !/^https?:\/\//.test(url)) {
|
||||
url = `https://${url}`;
|
||||
}
|
||||
|
||||
editor.chain().focus().extendMarkRange("link").setLink({ href: url }).run();
|
||||
setLinkInput({ open: false, url: "" });
|
||||
}, [editor, linkInput.url]);
|
||||
|
||||
const handleUnlink = useCallback(() => {
|
||||
if (!editor) return;
|
||||
editor.chain().focus().unsetLink().run();
|
||||
setLinkInput({ open: false, url: "" });
|
||||
}, [editor]);
|
||||
|
||||
const disabled = !editor || preview;
|
||||
const iconSize = "size-4";
|
||||
|
||||
return (
|
||||
<div className="flex flex-col border-b border-border">
|
||||
<div className="flex items-center gap-0.5 px-2 py-1 flex-wrap">
|
||||
{/* Inline marks */}
|
||||
<ToolbarButton
|
||||
onClick={() => run((c) => c.toggleBold().run())}
|
||||
active={isActive("bold")}
|
||||
disabled={disabled}
|
||||
title="Bold (Ctrl+B)"
|
||||
>
|
||||
<Bold className={iconSize} />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
onClick={() => run((c) => c.toggleItalic().run())}
|
||||
active={isActive("italic")}
|
||||
disabled={disabled}
|
||||
title="Italic (Ctrl+I)"
|
||||
>
|
||||
<Italic className={iconSize} />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
onClick={() => run((c) => c.toggleStrike().run())}
|
||||
active={isActive("strike")}
|
||||
disabled={disabled}
|
||||
title="Strikethrough"
|
||||
>
|
||||
<Strikethrough className={iconSize} />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
onClick={() => run((c) => c.toggleCode().run())}
|
||||
active={isActive("code")}
|
||||
disabled={disabled}
|
||||
title="Inline code"
|
||||
>
|
||||
<Code className={iconSize} />
|
||||
</ToolbarButton>
|
||||
|
||||
<ToolbarSeparator />
|
||||
|
||||
{/* Headings */}
|
||||
<ToolbarButton
|
||||
onClick={() => run((c) => c.toggleHeading({ level: 1 }).run())}
|
||||
active={isActive("heading", { level: 1 })}
|
||||
disabled={disabled}
|
||||
title="Heading 1"
|
||||
>
|
||||
<Heading1 className={iconSize} />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
onClick={() => run((c) => c.toggleHeading({ level: 2 }).run())}
|
||||
active={isActive("heading", { level: 2 })}
|
||||
disabled={disabled}
|
||||
title="Heading 2"
|
||||
>
|
||||
<Heading2 className={iconSize} />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
onClick={() => run((c) => c.toggleHeading({ level: 3 }).run())}
|
||||
active={isActive("heading", { level: 3 })}
|
||||
disabled={disabled}
|
||||
title="Heading 3"
|
||||
>
|
||||
<Heading3 className={iconSize} />
|
||||
</ToolbarButton>
|
||||
|
||||
<ToolbarSeparator />
|
||||
|
||||
{/* Block elements */}
|
||||
<ToolbarButton
|
||||
onClick={() => run((c) => c.toggleBulletList().run())}
|
||||
active={isActive("bulletList")}
|
||||
disabled={disabled}
|
||||
title="Bullet list"
|
||||
>
|
||||
<List className={iconSize} />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
onClick={() => run((c) => c.toggleOrderedList().run())}
|
||||
active={isActive("orderedList")}
|
||||
disabled={disabled}
|
||||
title="Ordered list"
|
||||
>
|
||||
<ListOrdered className={iconSize} />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
onClick={() => run((c) => c.toggleBlockquote().run())}
|
||||
active={isActive("blockquote")}
|
||||
disabled={disabled}
|
||||
title="Blockquote"
|
||||
>
|
||||
<Quote className={iconSize} />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
onClick={() => run((c) => c.toggleCodeBlock().run())}
|
||||
active={isActive("codeBlock")}
|
||||
disabled={disabled}
|
||||
title="Code block"
|
||||
>
|
||||
<CodeSquare className={iconSize} />
|
||||
</ToolbarButton>
|
||||
|
||||
<ToolbarSeparator />
|
||||
|
||||
{/* Link */}
|
||||
{isActive("link") ? (
|
||||
<ToolbarButton
|
||||
onClick={handleUnlink}
|
||||
active
|
||||
disabled={disabled}
|
||||
title="Remove link"
|
||||
>
|
||||
<Unlink className={iconSize} />
|
||||
</ToolbarButton>
|
||||
) : (
|
||||
<ToolbarButton
|
||||
onClick={() => {
|
||||
if (disabled) return;
|
||||
// Get existing link href if cursor is on a link
|
||||
const attrs = editor?.getAttributes("link");
|
||||
setLinkInput({
|
||||
open: true,
|
||||
url: attrs?.href || "",
|
||||
});
|
||||
}}
|
||||
disabled={disabled}
|
||||
title="Insert link"
|
||||
>
|
||||
<Link className={iconSize} />
|
||||
</ToolbarButton>
|
||||
)}
|
||||
|
||||
{/* Horizontal rule */}
|
||||
<ToolbarButton
|
||||
onClick={() => run((c) => c.setHorizontalRule().run())}
|
||||
disabled={disabled}
|
||||
title="Horizontal rule"
|
||||
>
|
||||
<Minus className={iconSize} />
|
||||
</ToolbarButton>
|
||||
|
||||
{/* Spacer */}
|
||||
<div className="flex-1" />
|
||||
|
||||
{/* Preview toggle */}
|
||||
<ToolbarButton
|
||||
onClick={onTogglePreview}
|
||||
active={preview}
|
||||
title={preview ? "Edit" : "Preview"}
|
||||
>
|
||||
{preview ? (
|
||||
<Pencil className={iconSize} />
|
||||
) : (
|
||||
<Eye className={iconSize} />
|
||||
)}
|
||||
</ToolbarButton>
|
||||
</div>
|
||||
|
||||
{/* Link input row */}
|
||||
{linkInput.open && (
|
||||
<div className="flex items-center gap-2 px-2 py-1.5 border-t border-border bg-muted/30">
|
||||
<Link className="size-3.5 text-muted-foreground flex-shrink-0" />
|
||||
<input
|
||||
type="url"
|
||||
value={linkInput.url}
|
||||
onChange={(e) =>
|
||||
setLinkInput((s) => ({ ...s, url: e.target.value }))
|
||||
}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
handleLinkSubmit();
|
||||
} else if (e.key === "Escape") {
|
||||
setLinkInput({ open: false, url: "" });
|
||||
}
|
||||
}}
|
||||
placeholder="https://example.com"
|
||||
className="flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground/50"
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleLinkSubmit}
|
||||
className="text-xs px-2 py-0.5 bg-primary/20 text-primary rounded hover:bg-primary/30"
|
||||
>
|
||||
Apply
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setLinkInput({ open: false, url: "" })}
|
||||
className="text-xs px-2 py-0.5 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
304
src/lib/markdown-serializer.ts
Normal file
304
src/lib/markdown-serializer.ts
Normal file
@@ -0,0 +1,304 @@
|
||||
import { nip19 } from "nostr-tools";
|
||||
import type {
|
||||
EmojiTag,
|
||||
BlobAttachment,
|
||||
SerializedContent,
|
||||
} from "@/components/editor/MentionEditor";
|
||||
|
||||
/**
|
||||
* Serialize a Tiptap/ProseMirror document to markdown.
|
||||
*
|
||||
* Handles standard markdown formatting (headings, bold, italic, code, lists,
|
||||
* blockquotes, links, horizontal rules) plus Nostr-specific nodes (mentions,
|
||||
* custom emojis, blob attachments, event previews).
|
||||
*
|
||||
* Returns both the markdown string and extracted metadata (emoji tags, blob
|
||||
* attachments, address refs) needed for building Nostr events.
|
||||
*/
|
||||
export function serializeEditorToMarkdown(editor: any): SerializedContent {
|
||||
const emojiTags: EmojiTag[] = [];
|
||||
const blobAttachments: BlobAttachment[] = [];
|
||||
const addressRefs: Array<{
|
||||
kind: number;
|
||||
pubkey: string;
|
||||
identifier: string;
|
||||
}> = [];
|
||||
const seenEmojis = new Set<string>();
|
||||
const seenBlobs = new Set<string>();
|
||||
const seenAddrs = new Set<string>();
|
||||
|
||||
const ctx = {
|
||||
emojiTags,
|
||||
blobAttachments,
|
||||
addressRefs,
|
||||
seenEmojis,
|
||||
seenBlobs,
|
||||
seenAddrs,
|
||||
};
|
||||
|
||||
const doc = editor.state.doc;
|
||||
const text = serializeBlocks(doc, ctx, "");
|
||||
|
||||
return { text, emojiTags, blobAttachments, addressRefs };
|
||||
}
|
||||
|
||||
interface SerializerContext {
|
||||
emojiTags: EmojiTag[];
|
||||
blobAttachments: BlobAttachment[];
|
||||
addressRefs: Array<{ kind: number; pubkey: string; identifier: string }>;
|
||||
seenEmojis: Set<string>;
|
||||
seenBlobs: Set<string>;
|
||||
seenAddrs: Set<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize all block-level children of a node, joined by double newlines.
|
||||
*/
|
||||
function serializeBlocks(
|
||||
node: any,
|
||||
ctx: SerializerContext,
|
||||
indent: string,
|
||||
): string {
|
||||
const blocks: string[] = [];
|
||||
|
||||
node.forEach((child: any) => {
|
||||
const result = serializeBlock(child, ctx, indent);
|
||||
if (result !== null) {
|
||||
blocks.push(result);
|
||||
}
|
||||
});
|
||||
|
||||
return blocks.join("\n\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize a single block-level node to markdown.
|
||||
*/
|
||||
function serializeBlock(
|
||||
node: any,
|
||||
ctx: SerializerContext,
|
||||
indent: string,
|
||||
): string | null {
|
||||
switch (node.type.name) {
|
||||
case "paragraph":
|
||||
return indent + serializeInline(node, ctx);
|
||||
|
||||
case "heading": {
|
||||
const level = node.attrs.level || 1;
|
||||
const prefix = "#".repeat(Math.min(level, 6));
|
||||
return `${indent}${prefix} ${serializeInline(node, ctx)}`;
|
||||
}
|
||||
|
||||
case "codeBlock": {
|
||||
const lang = node.attrs.language || "";
|
||||
const code = node.textContent;
|
||||
return `${indent}\`\`\`${lang}\n${code}\n${indent}\`\`\``;
|
||||
}
|
||||
|
||||
case "blockquote": {
|
||||
const inner = serializeBlocks(node, ctx, "");
|
||||
return inner
|
||||
.split("\n")
|
||||
.map((line) => `${indent}> ${line}`)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
case "bulletList": {
|
||||
const items: string[] = [];
|
||||
node.forEach((item: any) => {
|
||||
const content = serializeListItemContent(item, ctx, indent + " ");
|
||||
items.push(`${indent}- ${content}`);
|
||||
});
|
||||
return items.join("\n");
|
||||
}
|
||||
|
||||
case "orderedList": {
|
||||
const items: string[] = [];
|
||||
const start = node.attrs.start || 1;
|
||||
node.forEach((item: any, _offset: number, idx: number) => {
|
||||
const num = start + idx;
|
||||
const content = serializeListItemContent(item, ctx, indent + " ");
|
||||
items.push(`${indent}${num}. ${content}`);
|
||||
});
|
||||
return items.join("\n");
|
||||
}
|
||||
|
||||
case "horizontalRule":
|
||||
return `${indent}---`;
|
||||
|
||||
case "blobAttachment": {
|
||||
const { url, sha256, mimeType, size, server } = node.attrs;
|
||||
if (!ctx.seenBlobs.has(sha256)) {
|
||||
ctx.seenBlobs.add(sha256);
|
||||
ctx.blobAttachments.push({ url, sha256, mimeType, size, server });
|
||||
}
|
||||
// Images become markdown images, others just the URL
|
||||
if (mimeType?.startsWith("image/")) {
|
||||
return `${indent}`;
|
||||
}
|
||||
return `${indent}${url}`;
|
||||
}
|
||||
|
||||
case "nostrEventPreview": {
|
||||
const { type, data } = node.attrs;
|
||||
// Collect address refs for manual a-tags
|
||||
if (type === "naddr" && data) {
|
||||
const key = `${data.kind}:${data.pubkey}:${data.identifier || ""}`;
|
||||
if (!ctx.seenAddrs.has(key)) {
|
||||
ctx.seenAddrs.add(key);
|
||||
ctx.addressRefs.push({
|
||||
kind: data.kind,
|
||||
pubkey: data.pubkey,
|
||||
identifier: data.identifier || "",
|
||||
});
|
||||
}
|
||||
}
|
||||
return `${indent}${renderNostrEventPreviewText(type, data)}`;
|
||||
}
|
||||
|
||||
default:
|
||||
// For unknown block nodes, try to get text content
|
||||
if (node.textContent) {
|
||||
return indent + node.textContent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize a list item's children. The first paragraph is inlined,
|
||||
* subsequent blocks get their own lines with indentation.
|
||||
*/
|
||||
function serializeListItemContent(
|
||||
item: any,
|
||||
ctx: SerializerContext,
|
||||
continuationIndent: string,
|
||||
): string {
|
||||
const parts: string[] = [];
|
||||
let first = true;
|
||||
|
||||
item.forEach((child: any) => {
|
||||
if (first) {
|
||||
// First child is inlined (no leading indent)
|
||||
parts.push(serializeBlock(child, ctx, "") || "");
|
||||
first = false;
|
||||
} else {
|
||||
// Subsequent children get continuation indent
|
||||
parts.push(serializeBlock(child, ctx, continuationIndent) || "");
|
||||
}
|
||||
});
|
||||
|
||||
return parts.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize inline content of a block node (text with marks + inline nodes).
|
||||
*/
|
||||
function serializeInline(node: any, ctx: SerializerContext): string {
|
||||
let result = "";
|
||||
|
||||
node.forEach((child: any) => {
|
||||
if (child.isText) {
|
||||
let text = child.text || "";
|
||||
// Apply marks — order matters: link wraps bold wraps italic etc.
|
||||
const marks = [...child.marks].sort(markPriority);
|
||||
for (const mark of marks) {
|
||||
text = applyMark(mark, text);
|
||||
}
|
||||
result += text;
|
||||
} else {
|
||||
result += serializeInlineNode(child, ctx);
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort marks so nesting is correct: innermost marks first.
|
||||
* code < strike < italic < bold < link
|
||||
*/
|
||||
function markPriority(a: any, b: any): number {
|
||||
const order: Record<string, number> = {
|
||||
code: 0,
|
||||
strike: 1,
|
||||
italic: 2,
|
||||
bold: 3,
|
||||
link: 4,
|
||||
};
|
||||
return (order[a.type.name] ?? 5) - (order[b.type.name] ?? 5);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap text with the markdown syntax for a mark.
|
||||
*/
|
||||
function applyMark(mark: any, text: string): string {
|
||||
switch (mark.type.name) {
|
||||
case "bold":
|
||||
return `**${text}**`;
|
||||
case "italic":
|
||||
return `*${text}*`;
|
||||
case "code":
|
||||
return `\`${text}\``;
|
||||
case "strike":
|
||||
return `~~${text}~~`;
|
||||
case "link":
|
||||
return `[${text}](${mark.attrs.href || ""})`;
|
||||
default:
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize a non-text inline node (mention, emoji, hardBreak).
|
||||
*/
|
||||
function serializeInlineNode(node: any, ctx: SerializerContext): string {
|
||||
switch (node.type.name) {
|
||||
case "mention": {
|
||||
try {
|
||||
return `nostr:${nip19.npubEncode(node.attrs.id)}`;
|
||||
} catch {
|
||||
return `@${node.attrs.label || "unknown"}`;
|
||||
}
|
||||
}
|
||||
|
||||
case "emoji": {
|
||||
const { id, url, source } = node.attrs;
|
||||
if (source === "unicode") {
|
||||
return url || "";
|
||||
}
|
||||
// Custom emoji — collect tag
|
||||
if (!ctx.seenEmojis.has(id)) {
|
||||
ctx.seenEmojis.add(id);
|
||||
ctx.emojiTags.push({ shortcode: id, url });
|
||||
}
|
||||
return `:${id}:`;
|
||||
}
|
||||
|
||||
case "hardBreak":
|
||||
return "\n";
|
||||
|
||||
default:
|
||||
return node.textContent || "";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a nostr event preview node back to its bech32 URI.
|
||||
*/
|
||||
function renderNostrEventPreviewText(type: string, data: any): string {
|
||||
try {
|
||||
switch (type) {
|
||||
case "note":
|
||||
return `nostr:${nip19.noteEncode(data)}`;
|
||||
case "nevent":
|
||||
return `nostr:${nip19.neventEncode(data)}`;
|
||||
case "naddr":
|
||||
return `nostr:${nip19.naddrEncode(data)}`;
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user