mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-09 23:16:50 +02:00
refactor(editor): replace all any types with proper ProseMirror and Tiptap types
- markdown-serializer.ts: Use Editor, ProseMirrorNode, ProseMirrorMark, EventPointer, AddressPointer instead of any. Type-assert node.attrs values explicitly. - MarkdownEditor.tsx: Use Editor for submit refs/callbacks, JSONContent for getJSON/setContent handle. Properly type suggestion command callbacks with Tiptap's inferred MentionNodeAttrs + runtime casts. - MarkdownToolbar.tsx: Use ChainedCommands for toolbar run callback, narrower Record type for isActive attrs. https://claude.ai/code/session_01TUzfLDbarxHDYQRA2fyYTr
This commit is contained in:
@@ -7,7 +7,7 @@ import {
|
||||
useState,
|
||||
} from "react";
|
||||
import { useEditor, EditorContent, ReactRenderer } from "@tiptap/react";
|
||||
import { Extension } from "@tiptap/core";
|
||||
import { Extension, type Editor, type JSONContent } from "@tiptap/core";
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
import Mention from "@tiptap/extension-mention";
|
||||
import Placeholder from "@tiptap/extension-placeholder";
|
||||
@@ -65,9 +65,9 @@ export interface MarkdownEditorHandle {
|
||||
/** Insert a blob attachment with rich preview */
|
||||
insertBlob: (blob: BlobAttachment) => void;
|
||||
/** Get editor state as JSON (for persistence/drafts) */
|
||||
getJSON: () => any;
|
||||
getJSON: () => JSONContent | null;
|
||||
/** Restore editor content from JSON */
|
||||
setContent: (json: any) => void;
|
||||
setContent: (json: JSONContent) => void;
|
||||
}
|
||||
|
||||
// Create emoji extension by extending Mention with a different name and custom node view
|
||||
@@ -159,7 +159,7 @@ export const MarkdownEditor = forwardRef<
|
||||
) => {
|
||||
const [preview, setPreview] = useState(false);
|
||||
const [previewContent, setPreviewContent] = useState("");
|
||||
const handleSubmitRef = useRef<(editor: any) => void>(() => {});
|
||||
const handleSubmitRef = useRef<(editor: Editor) => void>(() => {});
|
||||
|
||||
// Create mention suggestion configuration
|
||||
const mentionSuggestion: Omit<SuggestionOptions, "editor"> = useMemo(
|
||||
@@ -289,7 +289,7 @@ export const MarkdownEditor = forwardRef<
|
||||
|
||||
// Handle submit
|
||||
const handleSubmit = useCallback(
|
||||
(editorInstance: any) => {
|
||||
(editorInstance: Editor) => {
|
||||
if (editorInstance.isEmpty) return;
|
||||
|
||||
const serialized = serializeEditorToMarkdown(editorInstance);
|
||||
@@ -340,17 +340,16 @@ export const MarkdownEditor = forwardRef<
|
||||
HTMLAttributes: { class: "mention" },
|
||||
suggestion: {
|
||||
...mentionSuggestion,
|
||||
command: ({ editor, range, props }: any) => {
|
||||
editor
|
||||
.chain()
|
||||
command: ({ editor: ed, range: r, props: mentionAttrs }) => {
|
||||
// items() returns ProfileSearchResult[]; the selected item is
|
||||
// passed as props at runtime despite being typed as MentionNodeAttrs
|
||||
const p = mentionAttrs as unknown as ProfileSearchResult;
|
||||
ed.chain()
|
||||
.focus()
|
||||
.insertContentAt(range, [
|
||||
.insertContentAt(r, [
|
||||
{
|
||||
type: "mention",
|
||||
attrs: {
|
||||
id: props.pubkey,
|
||||
label: props.displayName,
|
||||
},
|
||||
attrs: { id: p.pubkey, label: p.displayName },
|
||||
},
|
||||
{ type: "text", text: " " },
|
||||
])
|
||||
@@ -374,18 +373,20 @@ export const MarkdownEditor = forwardRef<
|
||||
HTMLAttributes: { class: "emoji" },
|
||||
suggestion: {
|
||||
...emojiSuggestion,
|
||||
command: ({ editor, range, props }: any) => {
|
||||
editor
|
||||
.chain()
|
||||
command: ({ editor: ed, range: r, props: mentionAttrs }) => {
|
||||
// items() returns EmojiSearchResult[]; the selected item is
|
||||
// passed as props at runtime despite being typed as MentionNodeAttrs
|
||||
const p = mentionAttrs as unknown as EmojiSearchResult;
|
||||
ed.chain()
|
||||
.focus()
|
||||
.insertContentAt(range, [
|
||||
.insertContentAt(r, [
|
||||
{
|
||||
type: "emoji",
|
||||
attrs: {
|
||||
id: props.shortcode,
|
||||
label: props.shortcode,
|
||||
url: props.url,
|
||||
source: props.source,
|
||||
id: p.shortcode,
|
||||
label: p.shortcode,
|
||||
url: p.url,
|
||||
source: p.source,
|
||||
},
|
||||
},
|
||||
{ type: "text", text: " " },
|
||||
@@ -484,7 +485,7 @@ export const MarkdownEditor = forwardRef<
|
||||
if (!isEditorReady()) return null;
|
||||
return editor?.getJSON() || null;
|
||||
},
|
||||
setContent: (json: any) => {
|
||||
setContent: (json: JSONContent) => {
|
||||
if (isEditorReady() && json) {
|
||||
editor?.commands.setContent(json);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import type { Editor } from "@tiptap/core";
|
||||
import type { Editor, ChainedCommands } from "@tiptap/core";
|
||||
import {
|
||||
Bold,
|
||||
Italic,
|
||||
@@ -83,7 +83,7 @@ export function MarkdownToolbar({
|
||||
}>({ open: false, url: "" });
|
||||
|
||||
const isActive = useCallback(
|
||||
(name: string, attrs?: Record<string, any>) => {
|
||||
(name: string, attrs?: Record<string, number | string | boolean>) => {
|
||||
if (!editor) return false;
|
||||
return editor.isActive(name, attrs);
|
||||
},
|
||||
@@ -91,7 +91,7 @@ export function MarkdownToolbar({
|
||||
);
|
||||
|
||||
const run = useCallback(
|
||||
(command: (chain: any) => any) => {
|
||||
(command: (chain: ChainedCommands) => void) => {
|
||||
if (!editor) return;
|
||||
command(editor.chain().focus());
|
||||
},
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import type { Editor } from "@tiptap/core";
|
||||
import type {
|
||||
Node as ProseMirrorNode,
|
||||
Mark as ProseMirrorMark,
|
||||
} from "@tiptap/pm/model";
|
||||
import { nip19 } from "nostr-tools";
|
||||
import type { EventPointer, AddressPointer } from "nostr-tools/nip19";
|
||||
import type {
|
||||
EmojiTag,
|
||||
BlobAttachment,
|
||||
@@ -15,7 +21,7 @@ import type {
|
||||
* 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 {
|
||||
export function serializeEditorToMarkdown(editor: Editor): SerializedContent {
|
||||
const emojiTags: EmojiTag[] = [];
|
||||
const blobAttachments: BlobAttachment[] = [];
|
||||
const addressRefs: Array<{
|
||||
@@ -27,7 +33,7 @@ export function serializeEditorToMarkdown(editor: any): SerializedContent {
|
||||
const seenBlobs = new Set<string>();
|
||||
const seenAddrs = new Set<string>();
|
||||
|
||||
const ctx = {
|
||||
const ctx: SerializerContext = {
|
||||
emojiTags,
|
||||
blobAttachments,
|
||||
addressRefs,
|
||||
@@ -55,13 +61,13 @@ interface SerializerContext {
|
||||
* Serialize all block-level children of a node, joined by double newlines.
|
||||
*/
|
||||
function serializeBlocks(
|
||||
node: any,
|
||||
node: ProseMirrorNode,
|
||||
ctx: SerializerContext,
|
||||
indent: string,
|
||||
): string {
|
||||
const blocks: string[] = [];
|
||||
|
||||
node.forEach((child: any) => {
|
||||
node.forEach((child) => {
|
||||
const result = serializeBlock(child, ctx, indent);
|
||||
if (result !== null) {
|
||||
blocks.push(result);
|
||||
@@ -75,7 +81,7 @@ function serializeBlocks(
|
||||
* Serialize a single block-level node to markdown.
|
||||
*/
|
||||
function serializeBlock(
|
||||
node: any,
|
||||
node: ProseMirrorNode,
|
||||
ctx: SerializerContext,
|
||||
indent: string,
|
||||
): string | null {
|
||||
@@ -84,13 +90,13 @@ function serializeBlock(
|
||||
return indent + serializeInline(node, ctx);
|
||||
|
||||
case "heading": {
|
||||
const level = node.attrs.level || 1;
|
||||
const level = (node.attrs.level as number) || 1;
|
||||
const prefix = "#".repeat(Math.min(level, 6));
|
||||
return `${indent}${prefix} ${serializeInline(node, ctx)}`;
|
||||
}
|
||||
|
||||
case "codeBlock": {
|
||||
const lang = node.attrs.language || "";
|
||||
const lang = (node.attrs.language as string) || "";
|
||||
const code = node.textContent;
|
||||
return `${indent}\`\`\`${lang}\n${code}\n${indent}\`\`\``;
|
||||
}
|
||||
@@ -105,7 +111,7 @@ function serializeBlock(
|
||||
|
||||
case "bulletList": {
|
||||
const items: string[] = [];
|
||||
node.forEach((item: any) => {
|
||||
node.forEach((item) => {
|
||||
const content = serializeListItemContent(item, ctx, indent + " ");
|
||||
items.push(`${indent}- ${content}`);
|
||||
});
|
||||
@@ -114,8 +120,8 @@ function serializeBlock(
|
||||
|
||||
case "orderedList": {
|
||||
const items: string[] = [];
|
||||
const start = node.attrs.start || 1;
|
||||
node.forEach((item: any, _offset: number, idx: number) => {
|
||||
const start = (node.attrs.start as number) || 1;
|
||||
node.forEach((item, _offset, idx) => {
|
||||
const num = start + idx;
|
||||
const content = serializeListItemContent(item, ctx, indent + " ");
|
||||
items.push(`${indent}${num}. ${content}`);
|
||||
@@ -127,7 +133,11 @@ function serializeBlock(
|
||||
return `${indent}---`;
|
||||
|
||||
case "blobAttachment": {
|
||||
const { url, sha256, mimeType, size, server } = node.attrs;
|
||||
const url = node.attrs.url as string;
|
||||
const sha256 = node.attrs.sha256 as string;
|
||||
const mimeType = node.attrs.mimeType as string | undefined;
|
||||
const size = node.attrs.size as number | undefined;
|
||||
const server = node.attrs.server as string | undefined;
|
||||
if (!ctx.seenBlobs.has(sha256)) {
|
||||
ctx.seenBlobs.add(sha256);
|
||||
ctx.blobAttachments.push({ url, sha256, mimeType, size, server });
|
||||
@@ -140,20 +150,25 @@ function serializeBlock(
|
||||
}
|
||||
|
||||
case "nostrEventPreview": {
|
||||
const { type, data } = node.attrs;
|
||||
const previewType = node.attrs.type as string;
|
||||
const previewData = node.attrs.data as
|
||||
| string
|
||||
| EventPointer
|
||||
| AddressPointer;
|
||||
// Collect address refs for manual a-tags
|
||||
if (type === "naddr" && data) {
|
||||
const key = `${data.kind}:${data.pubkey}:${data.identifier || ""}`;
|
||||
if (previewType === "naddr" && previewData) {
|
||||
const addr = previewData as AddressPointer;
|
||||
const key = `${addr.kind}:${addr.pubkey}:${addr.identifier || ""}`;
|
||||
if (!ctx.seenAddrs.has(key)) {
|
||||
ctx.seenAddrs.add(key);
|
||||
ctx.addressRefs.push({
|
||||
kind: data.kind,
|
||||
pubkey: data.pubkey,
|
||||
identifier: data.identifier || "",
|
||||
kind: addr.kind,
|
||||
pubkey: addr.pubkey,
|
||||
identifier: addr.identifier || "",
|
||||
});
|
||||
}
|
||||
}
|
||||
return `${indent}${renderNostrEventPreviewText(type, data)}`;
|
||||
return `${indent}${renderNostrEventPreviewText(previewType, previewData)}`;
|
||||
}
|
||||
|
||||
default:
|
||||
@@ -170,14 +185,14 @@ function serializeBlock(
|
||||
* subsequent blocks get their own lines with indentation.
|
||||
*/
|
||||
function serializeListItemContent(
|
||||
item: any,
|
||||
item: ProseMirrorNode,
|
||||
ctx: SerializerContext,
|
||||
continuationIndent: string,
|
||||
): string {
|
||||
const parts: string[] = [];
|
||||
let first = true;
|
||||
|
||||
item.forEach((child: any) => {
|
||||
item.forEach((child) => {
|
||||
if (first) {
|
||||
// First child is inlined (no leading indent)
|
||||
parts.push(serializeBlock(child, ctx, "") || "");
|
||||
@@ -194,10 +209,13 @@ function serializeListItemContent(
|
||||
/**
|
||||
* Serialize inline content of a block node (text with marks + inline nodes).
|
||||
*/
|
||||
function serializeInline(node: any, ctx: SerializerContext): string {
|
||||
function serializeInline(
|
||||
node: ProseMirrorNode,
|
||||
ctx: SerializerContext,
|
||||
): string {
|
||||
let result = "";
|
||||
|
||||
node.forEach((child: any) => {
|
||||
node.forEach((child) => {
|
||||
if (child.isText) {
|
||||
let text = child.text || "";
|
||||
// Apply marks — order matters: link wraps bold wraps italic etc.
|
||||
@@ -218,7 +236,7 @@ function serializeInline(node: any, ctx: SerializerContext): string {
|
||||
* Sort marks so nesting is correct: innermost marks first.
|
||||
* code < strike < italic < bold < link
|
||||
*/
|
||||
function markPriority(a: any, b: any): number {
|
||||
function markPriority(a: ProseMirrorMark, b: ProseMirrorMark): number {
|
||||
const order: Record<string, number> = {
|
||||
code: 0,
|
||||
strike: 1,
|
||||
@@ -232,7 +250,7 @@ function markPriority(a: any, b: any): number {
|
||||
/**
|
||||
* Wrap text with the markdown syntax for a mark.
|
||||
*/
|
||||
function applyMark(mark: any, text: string): string {
|
||||
function applyMark(mark: ProseMirrorMark, text: string): string {
|
||||
switch (mark.type.name) {
|
||||
case "bold":
|
||||
return `**${text}**`;
|
||||
@@ -243,7 +261,7 @@ function applyMark(mark: any, text: string): string {
|
||||
case "strike":
|
||||
return `~~${text}~~`;
|
||||
case "link":
|
||||
return `[${text}](${mark.attrs.href || ""})`;
|
||||
return `[${text}](${(mark.attrs.href as string) || ""})`;
|
||||
default:
|
||||
return text;
|
||||
}
|
||||
@@ -252,18 +270,23 @@ function applyMark(mark: any, text: string): string {
|
||||
/**
|
||||
* Serialize a non-text inline node (mention, emoji, hardBreak).
|
||||
*/
|
||||
function serializeInlineNode(node: any, ctx: SerializerContext): string {
|
||||
function serializeInlineNode(
|
||||
node: ProseMirrorNode,
|
||||
ctx: SerializerContext,
|
||||
): string {
|
||||
switch (node.type.name) {
|
||||
case "mention": {
|
||||
try {
|
||||
return `nostr:${nip19.npubEncode(node.attrs.id)}`;
|
||||
return `nostr:${nip19.npubEncode(node.attrs.id as string)}`;
|
||||
} catch {
|
||||
return `@${node.attrs.label || "unknown"}`;
|
||||
return `@${(node.attrs.label as string) || "unknown"}`;
|
||||
}
|
||||
}
|
||||
|
||||
case "emoji": {
|
||||
const { id, url, source } = node.attrs;
|
||||
const id = node.attrs.id as string;
|
||||
const url = node.attrs.url as string;
|
||||
const source = node.attrs.source as string;
|
||||
if (source === "unicode") {
|
||||
return url || "";
|
||||
}
|
||||
@@ -286,15 +309,18 @@ function serializeInlineNode(node: any, ctx: SerializerContext): string {
|
||||
/**
|
||||
* Render a nostr event preview node back to its bech32 URI.
|
||||
*/
|
||||
function renderNostrEventPreviewText(type: string, data: any): string {
|
||||
function renderNostrEventPreviewText(
|
||||
type: string,
|
||||
data: string | EventPointer | AddressPointer,
|
||||
): string {
|
||||
try {
|
||||
switch (type) {
|
||||
case "note":
|
||||
return `nostr:${nip19.noteEncode(data)}`;
|
||||
return `nostr:${nip19.noteEncode(data as string)}`;
|
||||
case "nevent":
|
||||
return `nostr:${nip19.neventEncode(data)}`;
|
||||
return `nostr:${nip19.neventEncode(data as EventPointer)}`;
|
||||
case "naddr":
|
||||
return `nostr:${nip19.naddrEncode(data)}`;
|
||||
return `nostr:${nip19.naddrEncode(data as AddressPointer)}`;
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user