From 2336ac806210c83ae91ab37ed4230e56a13036ed Mon Sep 17 00:00:00 2001 From: Kobe Attias Date: Sun, 24 May 2026 20:38:17 -0400 Subject: [PATCH 1/5] Support docx, md converting + uploading via notebook --- app/notebook/api/convert/route.ts | 36 ++ components/Editor/extensions/Ai/Ai.ts | 6 +- .../extensions/TrailingNode/trailing-node.ts | 5 + components/Editor/lib/convert.ts | 244 ++++++++++ components/Notebook/LeftSidebar/index.tsx | 338 +++++++------ components/Notebook/NoteCreationModal.tsx | 450 ++++++++++++++++++ package-lock.json | 15 + package.json | 1 + 8 files changed, 943 insertions(+), 152 deletions(-) create mode 100644 app/notebook/api/convert/route.ts create mode 100644 components/Editor/lib/convert.ts create mode 100644 components/Notebook/NoteCreationModal.tsx diff --git a/app/notebook/api/convert/route.ts b/app/notebook/api/convert/route.ts new file mode 100644 index 000000000..74facb1d9 --- /dev/null +++ b/app/notebook/api/convert/route.ts @@ -0,0 +1,36 @@ +import jsonwebtoken from 'jsonwebtoken'; +import { getServerSession } from 'next-auth/next'; +import { authOptions } from '@/app/api/auth/[...nextauth]/auth.config'; + +const JWT_SECRET = process.env?.TIPTAP_CONVERT_SECRET; + +// Tokens expire after 15 minutes. Long enough to cover any reasonable docx +// upload (Tiptap's Convert service is synchronous and usually returns in +// 2-10s), short enough that a leaked token has a narrow useful window. +const JWT_EXPIRES_IN = '15m'; + +export async function POST(): Promise { + if (!JWT_SECRET) { + return new Response( + JSON.stringify({ + error: 'No convert token provided, please set TIPTAP_CONVERT_SECRET in your environment', + }), + { status: 403 } + ); + } + + // The route is already gated by next-auth middleware (matcher + // `/notebook/api/:path*`), so an anonymous request can't reach here. We + // still pull the session to bind the JWT to a specific user via the `sub` + // claim, which gives us audit attribution if Tiptap usage ever spikes. + const session = await getServerSession(authOptions); + if (!session?.userId) { + return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401 }); + } + + const jwt = jsonwebtoken.sign({ sub: session.userId }, JWT_SECRET, { + expiresIn: JWT_EXPIRES_IN, + }); + + return new Response(JSON.stringify({ token: jwt })); +} diff --git a/components/Editor/extensions/Ai/Ai.ts b/components/Editor/extensions/Ai/Ai.ts index abf7c72b3..19355fa67 100644 --- a/components/Editor/extensions/Ai/Ai.ts +++ b/components/Editor/extensions/Ai/Ai.ts @@ -1,5 +1,9 @@ import AiExtension from '@tiptap-pro/extension-ai'; -const TIPTAP_AI_APP_ID = process.env.NEXT_PUBLIC_TIPTAP_AI_APP_ID; + +// Account-wide Tiptap Cloud App ID. The same value is used by Content AI, +// Convert, and Collab — each product has its own JWT secret, but they all +// authenticate against the same account-scoped App ID. +const TIPTAP_AI_APP_ID = process.env.NEXT_PUBLIC_TIPTAP_APP_ID; const TIPTAP_AI_BASE_URL = process.env.NEXT_PUBLIC_TIPTAP_AI_BASE_URL || 'https://api.tiptap.dev/v1/ai'; diff --git a/components/Editor/extensions/TrailingNode/trailing-node.ts b/components/Editor/extensions/TrailingNode/trailing-node.ts index 8b2bf9fd3..5ae3632aa 100644 --- a/components/Editor/extensions/TrailingNode/trailing-node.ts +++ b/components/Editor/extensions/TrailingNode/trailing-node.ts @@ -3,6 +3,11 @@ import { Plugin, PluginKey } from '@tiptap/pm/state'; // @ts-ignore function nodeEqualsType({ types, node }) { + // Guard against a null `node` (state.init can see a doc whose lastChild is + // null, e.g. when the editor is constructed with empty content). Returning + // false here means the plugin will append the trailing node, which is the + // correct behavior for an empty document. + if (!node) return false; return (Array.isArray(types) && types.includes(node.type)) || node.type === types; } diff --git a/components/Editor/lib/convert.ts b/components/Editor/lib/convert.ts new file mode 100644 index 000000000..2aa868bd4 --- /dev/null +++ b/components/Editor/lib/convert.ts @@ -0,0 +1,244 @@ +'use client'; + +import { Editor, JSONContent } from '@tiptap/core'; +import type { AnyExtension } from '@tiptap/core'; +import { TextAlign } from '@tiptap/extension-text-align'; +import { History } from '@tiptap/extension-history'; +import { Import } from '@tiptap-pro/extension-import'; + +import { ExtensionKit } from '@/components/Editor/extensions/extension-kit'; +import { getDocumentTitle } from '@/components/Editor/lib/utils/documentTitle'; + +/** + * Supported source formats for the Tiptap Cloud Convert import flow. + * Mirrors the legacy `@tiptap-pro/extension-import` `formatMap`: + * - docx: Microsoft Word (routes to the `/import-docx` endpoint via the + * `experimentalDocxImport` flag). + * - odt: OpenDocument Text (LibreOffice, Google Docs export). + * - md: CommonMark / GitHub Flavored Markdown. We pass `format: 'gfm'` + * for any `.md` file so tables, task lists, and strikethrough parse + * correctly — the GFM superset is backward-compatible with CommonMark + * for everything else, so there's no downside. + */ +export type SupportedImportFormat = 'docx' | 'odt' | 'md'; + +const DOCX_MIME = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'; +const ODT_MIME = 'application/vnd.oasis.opendocument.text'; +const MD_MIMES = new Set(['text/markdown', 'text/x-markdown']); + +/** + * Server-enforced upload size cap (25 MB). The modal's file picker also + * blocks uploads larger than this, but we re-check here so the limit holds + * even if `importDocumentToTiptap` is called from another caller that + * doesn't run the modal's validation. + */ +const MAX_IMPORT_SIZE = 25 * 1024 * 1024; + +/** + * Resolve a file to one of our supported import formats, preferring MIME + * type when the OS provided one and falling back to the filename extension. + * Returns null for unsupported inputs so callers can produce a clear error. + */ +export const detectImportFormat = (file: File): SupportedImportFormat | null => { + const name = file.name.toLowerCase(); + + if (file.type === DOCX_MIME || name.endsWith('.docx')) return 'docx'; + if (file.type === ODT_MIME || name.endsWith('.odt')) return 'odt'; + if (MD_MIMES.has(file.type) || name.endsWith('.md') || name.endsWith('.markdown')) return 'md'; + + return null; +}; + +/** + * Strip the source-format extension from a filename so it can be used as a + * fallback note title. Only strips the extensions we actually accept so we + * don't accidentally mangle filenames like `data.archive.docx`. + */ +const stripImportExtension = (filename: string): string => + filename.replace(/\.(docx|odt|md|markdown)$/i, '').trim(); + +/** + * Fetch a fresh convert JWT for each import. Tokens expire after 15 minutes + * (see `/notebook/api/convert`), so a single fetch per import is the + * simplest correct behavior. Caching would force us to manage expiry + * client-side; not worth it given each import already pays the round-trip. + */ +const fetchConvertToken = async (): Promise => { + const response = await fetch('/notebook/api/convert', { method: 'POST' }); + if (!response.ok) { + throw new Error( + `Failed to fetch convert token (${response.status}). Is TIPTAP_CONVERT_SECRET set?` + ); + } + const body = (await response.json()) as { token?: string; error?: string }; + if (!body.token) { + throw new Error(body.error || 'Convert token response did not include a token.'); + } + return body.token; +}; + +export interface DocumentImportResult { + /** TipTap JSON document the live editor can render directly. */ + json: JSONContent; + /** Serialized HTML for the legacy `content` field on the note record. */ + html: string; + /** Plain text for search indexing and excerpts. */ + plainText: string; + /** Heuristic title pulled from the document (first heading, falls back to filename). */ + title: string; + /** The format the source file was detected as. */ + format: SupportedImportFormat; +} + +/** + * Convert a supported document (.docx, .odt, .md) to TipTap content by + * spinning up a headless editor that uses the same extension kit as the + * live note editor. The throwaway editor is destroyed before the function + * returns. + * + * Image handling: this v1 does not pass `imageUploadCallbackUrl` to the + * Import extension, so embedded images are stripped on import. Wiring image + * upload requires a public Next.js callback route plus either a server-side + * AWS SDK or a shared-secret Django endpoint. Tracked as a follow-up. + */ +export const importDocumentToTiptap = async ( + file: File, + options: { fallbackTitle?: string } = {} +): Promise => { + // The Tiptap Cloud App ID is account-wide and shared across products + // (Content AI, Convert, Collab). Each product has its own JWT secret on + // the server, but the App ID is the same value used by `Ai.ts`. + const appId = process.env.NEXT_PUBLIC_TIPTAP_APP_ID; + if (!appId) { + throw new Error( + 'NEXT_PUBLIC_TIPTAP_APP_ID is not set. Add it to your environment to enable document import.' + ); + } + + const format = detectImportFormat(file); + if (!format) { + throw new Error('Unsupported file type. Please upload a .docx, .odt, or .md document.'); + } + + if (file.size > MAX_IMPORT_SIZE) { + throw new Error( + `Document is too large (${(file.size / 1024 / 1024).toFixed(1)} MB). The maximum is ${MAX_IMPORT_SIZE / 1024 / 1024} MB.` + ); + } + + const token = await fetchConvertToken(); + + // The headless import editor uses the *default* Document node (which + // accepts `block+`) rather than the live editor's `heading block+` custom + // document. Two reasons: + // 1) Many imported documents don't start with a heading (especially + // markdown). Forcing `heading block+` during import would cause + // ProseMirror to coerce or drop the leading content, silently + // corrupting the imported document. + // 2) The live editor's schema is enforced at the editing layer; the + // stored JSON just needs to be coercible into it. We handle that + // coercion explicitly below by prepending a title heading if needed. + const extensions: AnyExtension[] = [ + ...ExtensionKit({}), + TextAlign.configure({ types: ['heading', 'paragraph'] }), + History.configure({ depth: 100 }), + Import.configure({ + appId, + token, + // experimentalDocxImport is required on legacy 2.x to opt into the + // DOCX-specific endpoint. It's a no-op for ODT/Markdown (the + // extension only takes the docx branch when the file MIME matches), + // so we can safely leave it on for all imports. + experimentalDocxImport: true, + }), + ]; + + // Initial content is a single paragraph rather than `{ content: [] }`. + // The TrailingNode plugin's state.init reads doc.lastChild, which is null + // for a truly empty doc and crashes the plugin. A single paragraph is the + // simplest valid seed that satisfies every plugin's invariants. + const editor = new Editor({ + extensions, + editable: false, + content: { type: 'doc', content: [{ type: 'paragraph' }] }, + }); + + try { + const json = await new Promise((resolve, reject) => { + let settled = false; + + // Safety net: surface a timeout rather than hanging the UI if onImport + // never fires (network outage, hung extension, etc). + const timeoutId = setTimeout(() => { + if (settled) return; + settled = true; + reject(new Error('Document import timed out after 60 seconds.')); + }, 60_000); + + editor + .chain() + .import({ + file, + // `format: 'gfm'` only matters for markdown (it flips a query + // flag on the Convert endpoint enabling GFM extensions). For + // docx/odt the extension ignores it. + ...(format === 'md' ? ({ format: 'gfm' } as const) : {}), + onImport(context) { + if (settled) return; + settled = true; + clearTimeout(timeoutId); + if (context.error) { + reject(context.error); + return; + } + // setEditorContent writes the normalized content into the + // throwaway editor; reading getJSON afterwards captures it + // shaped against our local schema (unknown nodes filtered). + context.setEditorContent(); + resolve(editor.getJSON()); + }, + }) + .run(); + }); + + // The live editor requires `heading block+`. If the imported doc doesn't + // start with a heading, prepend one derived from the filename so the + // result is loadable in the live editor without ProseMirror dropping or + // mangling the leading content. + const fallbackTitle = stripImportExtension(file.name) || options.fallbackTitle || 'Untitled'; + const normalizedJson = ensureLeadingHeading(json, fallbackTitle); + + const html = editor.getHTML(); + const plainText = editor.getText(); + const headingTitle = getDocumentTitle(normalizedJson); + const title = (headingTitle && headingTitle.trim()) || fallbackTitle; + + return { json: normalizedJson, html, plainText, title, format }; + } finally { + editor.destroy(); + } +}; + +/** + * Ensure the imported document starts with a level-1 heading. If the first + * block is already a heading we leave it alone. Otherwise we synthesize one + * from `fallbackTitle` so the resulting JSON satisfies the live editor's + * `heading block+` schema constraint. + */ +const ensureLeadingHeading = (doc: JSONContent, fallbackTitle: string): JSONContent => { + const firstBlock = doc.content?.[0]; + if (firstBlock?.type === 'heading') { + return doc; + } + return { + ...doc, + content: [ + { + type: 'heading', + attrs: { textAlign: 'left', level: 1 }, + content: [{ type: 'text', text: fallbackTitle }], + }, + ...(doc.content ?? []), + ], + }; +}; diff --git a/components/Notebook/LeftSidebar/index.tsx b/components/Notebook/LeftSidebar/index.tsx index 52fa0ed8a..154060c50 100644 --- a/components/Notebook/LeftSidebar/index.tsx +++ b/components/Notebook/LeftSidebar/index.tsx @@ -3,15 +3,12 @@ import { NoteList } from '@/components/Notebook/LeftSidebar/NoteList'; import { OrganizationSwitcher } from '@/components/Notebook/LeftSidebar/OrganizationSwitcher'; import { SidebarSection } from '@/components/Notebook/LeftSidebar/SidebarSection'; -import { BaseMenu, BaseMenuItem } from '@/components/ui/form/BaseMenu'; import { Button } from '@/components/ui/Button'; import { useOrganizationContext } from '@/contexts/OrganizationContext'; -import { Plus, Lock, Loader2, File, FileText, type LucideIcon } from 'lucide-react'; -import { FundingIcon } from '@/components/ui/icons/FundingIcon'; -import Icon from '@/components/ui/icons/Icon'; +import { Plus, Lock, Loader2, FileText, type LucideIcon } from 'lucide-react'; import { Organization } from '@/types/organization'; import { useRouter } from 'next/navigation'; -import { useCallback, useTransition } from 'react'; +import { useCallback, useState, useTransition } from 'react'; import { useNoteContent, useCreateNote } from '@/hooks/useNote'; import { getInitialContent } from '@/components/Editor/lib/data/initialContent'; import { @@ -22,33 +19,48 @@ import toast from 'react-hot-toast'; import { useNotebookContext } from '@/contexts/NotebookContext'; import grantTemplate from '@/components/Editor/lib/data/grantTemplate'; import proposalTemplate from '@/components/Editor/lib/data/proposalTemplate'; +import { NoteCreationModal, NoteCreationType } from '@/components/Notebook/NoteCreationModal'; +import { importDocumentToTiptap } from '@/components/Editor/lib/convert'; -const TEMPLATE_ITEMS = [ - { - id: 'preregistration' as const, - title: 'Proposal', - description: 'Crowdfund your research', - icon: , - }, - { - id: 'grant' as const, - title: 'Funding Opportunity', - description: 'Fund specific research you care about', - icon: , - }, - { - id: 'research' as const, - title: 'Preprint', - description: 'Publish your research as a preprint', - icon: , - }, - { - id: 'empty' as const, - title: 'Empty', - description: 'Start with a blank page', - icon: , - }, -]; +type Grouping = 'workspace' | 'private'; + +type TemplateId = 'preregistration' | 'grant' | 'research'; + +// Map our internal template ids onto the Django document_type strings the +// publishing form and downstream APIs key off of. Mirrors the mapping used in +// `app/notebook/[orgSlug]/page.tsx`. +const TEMPLATE_TO_DOCUMENT_TYPE: Record = { + preregistration: 'PREREGISTRATION', + grant: 'GRANT', + research: 'DISCUSSION', +}; + +const EMPTY_TEMPLATE = { + type: 'doc' as const, + content: [ + { + type: 'heading', + attrs: { textAlign: 'left', level: 1 }, + content: [{ type: 'text', text: 'Untitled' }], + }, + { + type: 'paragraph', + attrs: { class: null, textAlign: 'left' }, + }, + ], +}; + +const getTemplateContent = (templateId: TemplateId) => { + switch (templateId) { + case 'grant': + return grantTemplate; + case 'preregistration': + return proposalTemplate; + case 'research': + default: + return getInitialContent('research'); + } +}; export const LeftSidebar = () => { const router = useRouter(); @@ -64,6 +76,13 @@ export const LeftSidebar = () => { } = useOrganizationContext(); const { notes, isLoading: isLoadingNotes, refreshNotes } = useNotebookContext(); + // Which section's "+" was clicked, or null when the modal is closed. + const [activeModalGrouping, setActiveModalGrouping] = useState(null); + // Distinct from createNote/updateNote loading because the conversion call + // happens before either of those fire — we want the modal to show "Importing" + // for the full duration including the network round-trip. + const [isImporting, setIsImporting] = useState(false); + const handleOrgSelect = useCallback( async (org: Organization) => { setSelectedOrg(org); @@ -77,62 +96,39 @@ export const LeftSidebar = () => { toast.error('Failed to switch organization. Please try again.'); } }, - [router, startTransition] + [router, setSelectedOrg] ); - const handleTemplateSelect = useCallback( - async ( - type: 'workspace' | 'private', - template: 'research' | 'grant' | 'preregistration' | 'empty' - ) => { + const createNoteWithContent = useCallback( + async ({ + grouping, + template, + templateContent, + documentType, + }: { + grouping: Grouping; + template: TemplateId | 'empty'; + templateContent: ReturnType | typeof EMPTY_TEMPLATE; + documentType?: string; + }) => { if (!selectedOrg) return; try { - let contentTemplate; - switch (template) { - case 'research': - contentTemplate = getInitialContent('research'); - break; - case 'grant': - contentTemplate = grantTemplate; - break; - case 'preregistration': - contentTemplate = proposalTemplate; - break; - case 'empty': - contentTemplate = { - type: 'doc', - content: [ - { - type: 'heading', - attrs: { textAlign: 'left', level: 1 }, - content: [{ type: 'text', text: 'Untitled' }], - }, - { - type: 'paragraph', - attrs: { class: null, textAlign: 'left' }, - }, - ], - }; - break; - default: - contentTemplate = getInitialContent('research'); - break; - } - const newNote = await createNote({ - title: getDocumentTitle(contentTemplate) || 'Untitled', - grouping: type.toUpperCase() as 'WORKSPACE' | 'PRIVATE', + title: getDocumentTitle(templateContent) || 'Untitled', + grouping: grouping.toUpperCase() as 'WORKSPACE' | 'PRIVATE', organizationSlug: selectedOrg.slug, + documentType, }); await updateNoteContent({ note: newNote.id, - fullJson: JSON.stringify(contentTemplate), - plainText: getTemplatePlainText(contentTemplate), + fullJson: JSON.stringify(templateContent), + plainText: getTemplatePlainText(templateContent), }); refreshNotes(); + setActiveModalGrouping(null); router.push(`/notebook/${selectedOrg.slug}/${newNote.id}?template=${template}`); } catch (error) { console.error('Error creating note:', error); @@ -141,97 +137,121 @@ export const LeftSidebar = () => { }); } }, - [createNote, updateNoteContent, router, selectedOrg, refreshNotes] + [createNote, updateNoteContent, refreshNotes, router, selectedOrg] + ); + + const handleCreateFromTemplate = useCallback( + async (grouping: Grouping, type: TemplateId) => { + await createNoteWithContent({ + grouping, + template: type, + templateContent: getTemplateContent(type), + documentType: TEMPLATE_TO_DOCUMENT_TYPE[type], + }); + }, + [createNoteWithContent] + ); + + const handleCreateBlank = useCallback( + async (grouping: Grouping) => { + await createNoteWithContent({ + grouping, + template: 'empty', + templateContent: EMPTY_TEMPLATE, + }); + }, + [createNoteWithContent] + ); + + const handleCreateFromUpload = useCallback( + async (grouping: Grouping, { file, type }: { file: File; type: NoteCreationType }) => { + if (!selectedOrg) return; + setIsImporting(true); + try { + const result = await importDocumentToTiptap(file); + const documentType = + type === 'other' ? undefined : TEMPLATE_TO_DOCUMENT_TYPE[type as TemplateId]; + + const newNote = await createNote({ + title: result.title, + grouping: grouping.toUpperCase() as 'WORKSPACE' | 'PRIVATE', + organizationSlug: selectedOrg.slug, + documentType, + }); + + await updateNoteContent({ + note: newNote.id, + fullSrc: result.html, + fullJson: JSON.stringify(result.json), + plainText: result.plainText, + }); + + refreshNotes(); + setActiveModalGrouping(null); + // No ?template= query — the import gave us real content, not a scaffold. + router.push(`/notebook/${selectedOrg.slug}/${newNote.id}`); + } catch (error) { + console.error('Error importing document:', error); + const message = + error instanceof Error + ? error.message + : 'Failed to import document. Please try a different file.'; + toast.error(message, { style: { width: '320px' } }); + } finally { + setIsImporting(false); + } + }, + [createNote, updateNoteContent, refreshNotes, router, selectedOrg] ); - const isProcessing = isCreatingNote || isUpdatingContent; + const isProcessing = isCreatingNote || isUpdatingContent || isImporting; const hasWorkspaceNotes = notes?.some((n) => n.access === 'WORKSPACE' || n.access === 'SHARED'); const hasPrivateNotes = notes?.some((n) => n.access === 'PRIVATE'); - const renderTemplateMenu = (type: 'workspace' | 'private', triggerLabel?: string) => ( - - - {triggerLabel} - + const openModalFor = (grouping: Grouping) => () => setActiveModalGrouping(grouping); + + const renderAddButton = (grouping: Grouping, triggerLabel?: string) => + triggerLabel ? ( + + ) : ( + - ) - } - align="start" - className="w-[340px] p-2" - > -
-
-
-

- Select Template -

-
-
- {TEMPLATE_ITEMS.map((item) => ( - handleTemplateSelect(type, item.id)} - className="w-full px-2" - disabled={isProcessing} - > -
-
-
- {isProcessing ? ( - - ) : ( - item.icon - )} -
-
-
-
- {item.title} -
-
{item.description}
-
-
-
- ))} -
-
-
-
- ); + + )} + + ); const renderEmptyState = ({ icon: StateIcon, title, subtitle, buttonLabel, - type, + grouping, }: { icon: LucideIcon; title: string; subtitle: string; buttonLabel: string; - type: 'workspace' | 'private'; + grouping: Grouping; }) => (
@@ -239,7 +259,7 @@ export const LeftSidebar = () => {

{title}

{subtitle}

- {renderTemplateMenu(type, buttonLabel)} + {renderAddButton(grouping, buttonLabel)}
); @@ -258,7 +278,7 @@ export const LeftSidebar = () => { title="Workspace" icon={FileText} iconPosition="after" - action={renderTemplateMenu('workspace')} + action={renderAddButton('workspace')} > {hasWorkspaceNotes || isLoadingNotes || isLoadingOrgs ? ( { title: 'No notes yet', subtitle: 'Create your first note to get started', buttonLabel: 'Add New Note', - type: 'workspace', + grouping: 'workspace', }) )} @@ -283,7 +303,7 @@ export const LeftSidebar = () => { title="Private" icon={Lock} iconPosition="after" - action={renderTemplateMenu('private')} + action={renderAddButton('private')} > {hasPrivateNotes || isLoadingNotes || isLoadingOrgs ? ( { title: 'No private notes yet', subtitle: 'Private notes are only visible to you', buttonLabel: 'Add Private Note', - type: 'private', + grouping: 'private', }) )} + + { + if (!isProcessing) setActiveModalGrouping(null); + }} + grouping={activeModalGrouping ?? 'workspace'} + onCreateFromTemplate={(type) => + handleCreateFromTemplate(activeModalGrouping ?? 'workspace', type) + } + onCreateBlank={() => handleCreateBlank(activeModalGrouping ?? 'workspace')} + onCreateFromUpload={(params) => + handleCreateFromUpload(activeModalGrouping ?? 'workspace', params) + } + isProcessing={isProcessing} + /> ); }; diff --git a/components/Notebook/NoteCreationModal.tsx b/components/Notebook/NoteCreationModal.tsx new file mode 100644 index 000000000..972ba1277 --- /dev/null +++ b/components/Notebook/NoteCreationModal.tsx @@ -0,0 +1,450 @@ +'use client'; + +import { ReactNode, useEffect, useRef, useState } from 'react'; +import { ArrowLeft, File, FileText, Loader2, Upload, X as XIcon } from 'lucide-react'; +import { BaseModal } from '@/components/ui/BaseModal'; +import { Button } from '@/components/ui/Button'; +import { FundingIcon } from '@/components/ui/icons/FundingIcon'; +import Icon from '@/components/ui/icons/Icon'; +import { cn } from '@/utils/styles'; +import { detectImportFormat } from '@/components/Editor/lib/convert'; + +export type NoteCreationSource = 'template' | 'upload' | 'blank'; + +export type NoteCreationType = 'preregistration' | 'grant' | 'research' | 'other'; + +interface SourceOption { + id: NoteCreationSource; + title: string; + description: string; + icon: ReactNode; +} + +interface TypeOption { + id: NoteCreationType; + title: string; + description: string; + icon: ReactNode; +} + +const SOURCE_OPTIONS: SourceOption[] = [ + { + id: 'template', + title: 'From a template', + description: 'Pick a template to start with (proposal, preprint, ...)', + icon: , + }, + { + id: 'upload', + title: 'Upload a document', + description: 'Import a Word, Markdown, or OpenDocument', + icon: , + }, + { + id: 'blank', + title: 'Start blank', + description: 'Begin with an empty page', + icon: , + }, +]; + +// Type options when the user is in the template flow. "Other" is intentionally +// omitted because there's no generic scaffold; pick a type or go back. +const TYPE_OPTIONS_FOR_TEMPLATE: TypeOption[] = [ + { + id: 'preregistration', + title: 'Proposal', + description: 'Crowdfund your research', + icon: , + }, + { + id: 'grant', + title: 'Funding Opportunity', + description: 'Fund specific research you care about', + icon: , + }, + { + id: 'research', + title: 'Preprint', + description: 'Publish your research as a preprint', + icon: , + }, +]; + +// Type options for the upload flow include "Other" so an arbitrary import +// still has a home when it doesn't fit a typed publishing workflow. +const TYPE_OPTIONS_FOR_UPLOAD: TypeOption[] = [ + ...TYPE_OPTIONS_FOR_TEMPLATE, + { + id: 'other', + title: 'Other', + description: 'General document with no specific publishing type', + icon: , + }, +]; + +/** Maximum upload size in bytes (25 MB). Mirrors the server-side cap in convert.ts. */ +const MAX_FILE_SIZE = 25 * 1024 * 1024; + +// File-picker accept hint. Both MIME and extension are listed because OSes +// inconsistently report MIME for these formats (especially .md, which often +// arrives as `text/plain` or with no MIME at all). +const ACCEPT_ATTR = [ + '.docx', + '.odt', + '.md', + '.markdown', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'application/vnd.oasis.opendocument.text', + 'text/markdown', +].join(','); + +interface NoteCreationModalProps { + isOpen: boolean; + onClose: () => void; + /** Notebook section the new note will belong to. */ + grouping: 'workspace' | 'private'; + onCreateFromTemplate: (type: 'preregistration' | 'grant' | 'research') => Promise | void; + onCreateBlank: () => Promise | void; + onCreateFromUpload: (params: { file: File; type: NoteCreationType }) => Promise | void; + /** External processing flag from the parent (e.g. createNote pending). */ + isProcessing?: boolean; +} + +const TITLE_FOR_GROUPING: Record = { + workspace: 'Create workspace note', + private: 'Create private note', +}; + +// Wizard step the modal is showing. The upload flow has its own sub-step +// (file picker first, then type tiles) because we don't want to ask users +// "what type?" until we know they actually have a file to import. +type Step = + | { kind: 'source' } + | { kind: 'template-type' } + | { kind: 'upload-file' } + | { kind: 'upload-type'; file: File }; + +export const NoteCreationModal = ({ + isOpen, + onClose, + grouping, + onCreateFromTemplate, + onCreateBlank, + onCreateFromUpload, + isProcessing = false, +}: NoteCreationModalProps) => { + const [step, setStep] = useState({ kind: 'source' }); + const [type, setType] = useState(null); + const [fileError, setFileError] = useState(null); + const fileInputRef = useRef(null); + + useEffect(() => { + if (!isOpen) { + setStep({ kind: 'source' }); + setType(null); + setFileError(null); + } + }, [isOpen]); + + const handleSourceSelect = (next: NoteCreationSource) => { + setType(null); + setFileError(null); + + if (next === 'blank') { + // No second step for blank — fire-and-forget; the parent owns the + // loading state and closes the modal when the create flow resolves. + void onCreateBlank(); + return; + } + + if (next === 'template') { + setStep({ kind: 'template-type' }); + return; + } + + setStep({ kind: 'upload-file' }); + }; + + const handleBack = () => { + if (step.kind === 'upload-type') { + // Going back from type selection should land on the file picker, not + // the source list — the user explicitly chose "upload" and shouldn't + // be forced to re-select that. + setStep({ kind: 'upload-file' }); + setType(null); + return; + } + setStep({ kind: 'source' }); + setType(null); + setFileError(null); + }; + + const openFilePicker = () => fileInputRef.current?.click(); + + const handleFileChange = (event: React.ChangeEvent) => { + const picked = event.target.files?.[0] ?? null; + event.target.value = ''; // allow re-selecting the same file + if (!picked) return; + + if (!detectImportFormat(picked)) { + setFileError('Only .docx, .odt, and .md files are supported.'); + return; + } + + if (picked.size > MAX_FILE_SIZE) { + setFileError('That file is larger than 25 MB. Try a smaller document.'); + return; + } + + setFileError(null); + setStep({ kind: 'upload-type', file: picked }); + }; + + const handleChangeFile = () => { + setStep({ kind: 'upload-file' }); + setType(null); + }; + + const handleCreate = async () => { + if (step.kind === 'template-type') { + if (!type || type === 'other') return; + await onCreateFromTemplate(type); + return; + } + + if (step.kind === 'upload-type') { + if (!type) return; + await onCreateFromUpload({ file: step.file, type }); + } + }; + + const canSubmit = + !isProcessing && + ((step.kind === 'template-type' && type !== null && type !== 'other') || + (step.kind === 'upload-type' && type !== null)); + + const showBackArrow = step.kind !== 'source'; + + const titleNode = ( +
+ {showBackArrow && ( + + )} + {TITLE_FOR_GROUPING[grouping]} +
+ ); + + const showFooter = step.kind === 'template-type' || step.kind === 'upload-type'; + + const footerNode = showFooter ? ( +
+

+ {step.kind === 'template-type' && !type && <>Pick a document type to continue.} + {step.kind === 'upload-type' && !type && <>Pick a document type for the import.} + {step.kind === 'upload-type' && type && isProcessing && ( + <>Converting your document — this can take a few seconds... + )} +

+
+ + +
+
+ ) : undefined; + + return ( + + {step.kind === 'source' && ( +
+

+ How do you want to start? +

+
+ {SOURCE_OPTIONS.map((option) => ( + handleSourceSelect(option.id)} + /> + ))} +
+
+ )} + + {step.kind === 'template-type' && ( +
+

+ Which template do you want to use? +

+
+ {TYPE_OPTIONS_FOR_TEMPLATE.map((option) => ( + setType(option.id)} + /> + ))} +
+
+ )} + + {step.kind === 'upload-file' && ( +
+

+ Choose a document +

+ + {fileError && ( +

+ {fileError} +

+ )} + +
+ )} + + {step.kind === 'upload-type' && ( +
+
+

+ Selected file +

+
+
+ +
+
+
{step.file.name}
+
{(step.file.size / 1024).toFixed(0)} KB
+
+ +
+
+ +
+

+ What type of document is this? +

+
+ {TYPE_OPTIONS_FOR_UPLOAD.map((option) => ( + setType(option.id)} + /> + ))} +
+
+
+ )} +
+ ); +}; + +interface OptionTileProps { + title: string; + description: string; + icon: ReactNode; + onClick: () => void; + selected?: boolean; + disabled?: boolean; + isLoading?: boolean; +} + +const OptionTile = ({ + title, + description, + icon, + onClick, + selected = false, + disabled = false, + isLoading = false, +}: OptionTileProps) => ( + +); diff --git a/package-lock.json b/package-lock.json index 3c1168ed3..781b4449d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -37,6 +37,7 @@ "@tiptap-pro/extension-drag-handle-react": "^2.16.0", "@tiptap-pro/extension-emoji": "^2.16.0", "@tiptap-pro/extension-file-handler": "^2.16.0", + "@tiptap-pro/extension-import": "^2.21.8", "@tiptap-pro/extension-mathematics": "^2.16.0", "@tiptap-pro/extension-node-range": "^2.16.0", "@tiptap-pro/extension-table-of-contents": "^2.16.0", @@ -9860,6 +9861,20 @@ "@tiptap/pm": "^2.0.0" } }, + "node_modules/@tiptap-pro/extension-import": { + "version": "2.21.8", + "resolved": "https://registry.tiptap.dev/@tiptap-pro%2fextension-import/-/extension-import-2.21.8.tgz", + "integrity": "sha512-kkuChXOY0KplxGlk4KBPxADRNJEg/6aPJffUig11YpUoOgKC2GW14Ayo8FXcb0MrNvPAgCSAKuqK5Mo19i4biw==", + "license": "SEE LICENSE IN LICENSE.md", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.0.0", + "@tiptap/pm": "^2.0.0" + } + }, "node_modules/@tiptap-pro/extension-mathematics": { "version": "2.21.5", "resolved": "https://registry.tiptap.dev/@tiptap-pro%2fextension-mathematics/-/extension-mathematics-2.21.5.tgz", diff --git a/package.json b/package.json index 818ed8ba5..54b313adc 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "@tiptap-pro/extension-drag-handle-react": "^2.16.0", "@tiptap-pro/extension-emoji": "^2.16.0", "@tiptap-pro/extension-file-handler": "^2.16.0", + "@tiptap-pro/extension-import": "^2.21.8", "@tiptap-pro/extension-mathematics": "^2.16.0", "@tiptap-pro/extension-node-range": "^2.16.0", "@tiptap-pro/extension-table-of-contents": "^2.16.0", From fe36a446e0fea8d476d6d416acc50ac093ccd044 Mon Sep 17 00:00:00 2001 From: Kobe Attias Date: Sun, 24 May 2026 21:26:30 -0400 Subject: [PATCH 2/5] Include upload option in modal --- app/notebook/[orgSlug]/[noteId]/page.tsx | 36 ++---- app/notebook/[orgSlug]/page.tsx | 92 ++++++++++++-- components/modals/FundingTimelineModal.tsx | 133 +++++++++++++++++++-- 3 files changed, 212 insertions(+), 49 deletions(-) diff --git a/app/notebook/[orgSlug]/[noteId]/page.tsx b/app/notebook/[orgSlug]/[noteId]/page.tsx index 19c8217a2..f29c56221 100644 --- a/app/notebook/[orgSlug]/[noteId]/page.tsx +++ b/app/notebook/[orgSlug]/[noteId]/page.tsx @@ -1,29 +1,15 @@ 'use client'; -import { useRouter, useSearchParams } from 'next/navigation'; -import { useEffect, useState } from 'react'; -import { FundingTimelineModal } from '@/components/modals/FundingTimelineModal'; - +// This page intentionally renders nothing. The actual note editor is +// rendered by `app/notebook/[orgSlug]/layout.tsx`, which mounts the +// editor based on the URL. This file exists so that the route resolves +// when a user navigates to /notebook//. +// +// The funding-timeline modal used to render here when the org page +// auto-created a proposal and forwarded `?newFunding=true` to the new +// note's URL. That flow now lives on the org page itself +// (`app/notebook/[orgSlug]/page.tsx`) where the modal can drive the +// note creation, rather than appearing after the note already exists. export default function NotePage() { - const router = useRouter(); - const searchParams = useSearchParams(); - const isNewFunding = searchParams?.get('newFunding') === 'true'; - const [showFundingModal, setShowFundingModal] = useState(isNewFunding); - - const stripNewFundingParam = () => { - // Strip the one-time query param so the modal doesn't re-appear on refresh or back navigation - const url = new URL(globalThis.window.location.href); - url.searchParams.delete('newFunding'); - router.replace(url.pathname + url.search, { scroll: false }); - }; - - useEffect(() => { - if (isNewFunding) { - stripNewFundingParam(); - } - }, [isNewFunding]); // eslint-disable-line react-hooks/exhaustive-deps - - return ( - setShowFundingModal(false)} /> - ); + return null; } diff --git a/app/notebook/[orgSlug]/page.tsx b/app/notebook/[orgSlug]/page.tsx index 161cf9ab5..b8e0a7860 100644 --- a/app/notebook/[orgSlug]/page.tsx +++ b/app/notebook/[orgSlug]/page.tsx @@ -2,8 +2,9 @@ import { useOrganizationContext } from '@/contexts/OrganizationContext'; import { useNotebookContext } from '@/contexts/NotebookContext'; -import { useEffect } from 'react'; +import { useEffect, useState } from 'react'; import { useSearchParams, useRouter } from 'next/navigation'; +import toast from 'react-hot-toast'; import proposalTemplate from '@/components/Editor/lib/data/proposalTemplate'; import { getInitialContent, initialContent } from '@/components/Editor/lib/data/initialContent'; import grantTemplate from '@/components/Editor/lib/data/grantTemplate'; @@ -14,6 +15,8 @@ import { import { useCreateNote, useNoteContent } from '@/hooks/useNote'; import { NoteCreationPopover } from '@/components/Notebook/NoteCreationPopover'; import { NotePaperSkeleton } from '@/components/Notebook/NotePaperSkeleton'; +import { FundingTimelineModal } from '@/components/modals/FundingTimelineModal'; +import { importDocumentToTiptap } from '@/components/Editor/lib/convert'; export default function OrganizationPage() { const router = useRouter(); @@ -24,11 +27,23 @@ export default function OrganizationPage() { const [{ isLoading: isCreatingNote }, createNote] = useCreateNote(); const [{ isLoading: isUpdatingContent }, updateNoteContent] = useNoteContent(); + const [isImporting, setIsImporting] = useState(false); const isNewFunding = searchParams.get('newFunding') === 'true'; const isNewResearch = searchParams.get('newResearch') === 'true'; const isNewGrant = searchParams.get('newGrant') === 'true'; + const [showFundingModal, setShowFundingModal] = useState(false); + useEffect(() => { + if (isNewFunding) setShowFundingModal(true); + }, [isNewFunding]); + + const stripQueryParam = (key: string) => { + const url = new URL(globalThis.window.location.href); + url.searchParams.delete(key); + router.replace(url.pathname + url.search, { scroll: false }); + }; + const createNoteWithContent = async ( orgSlug: string, { @@ -71,14 +86,7 @@ export default function OrganizationPage() { useEffect(() => { if (!selectedOrg) return; - if (isNewFunding) { - createNoteWithContent(selectedOrg.slug, { - template: proposalTemplate, - queryParam: 'newFunding', - queryValue: 'true', - documentType: 'PREREGISTRATION', - }); - } else if (isNewResearch) { + if (isNewResearch) { createNoteWithContent(selectedOrg.slug, { template: getInitialContent('research'), queryParam: 'newResearch', @@ -93,11 +101,73 @@ export default function OrganizationPage() { documentType: 'GRANT', }); } - }, [selectedOrg, isNewFunding, isNewResearch, isNewGrant]); // eslint-disable-line react-hooks/exhaustive-deps + }, [selectedOrg, isNewResearch, isNewGrant]); // eslint-disable-line react-hooks/exhaustive-deps + + const handleStartFromTemplate = async () => { + if (!selectedOrg) return; + await createNoteWithContent(selectedOrg.slug, { + template: proposalTemplate, + queryParam: 'template', + queryValue: 'preregistration', + documentType: 'PREREGISTRATION', + }); + }; + + const handleUploadFile = async (file: File) => { + if (!selectedOrg) return; + setIsImporting(true); + try { + const result = await importDocumentToTiptap(file); + const newNote = await createNote({ + organizationSlug: selectedOrg.slug, + title: result.title, + grouping: 'WORKSPACE', + documentType: 'PREREGISTRATION', + }); + + if (newNote) { + await updateNoteContent({ + note: newNote.id, + fullSrc: result.html, + fullJson: JSON.stringify(result.json), + plainText: result.plainText, + }); + refreshNotes(); + router.replace(`/notebook/${selectedOrg.slug}/${newNote.id}`); + } + } catch (error) { + console.error('Failed to import proposal document:', error); + const message = + error instanceof Error + ? error.message + : 'Failed to import document. Please try a different file.'; + toast.error(message, { style: { width: '320px' } }); + } finally { + setIsImporting(false); + } + }; + + const handleFundingModalClose = () => { + setShowFundingModal(false); + stripQueryParam('newFunding'); + }; if (isLoadingOrg) { return ; } - return ; + const isProposalProcessing = isCreatingNote || isUpdatingContent || isImporting; + + return ( + <> + + + + ); } diff --git a/components/modals/FundingTimelineModal.tsx b/components/modals/FundingTimelineModal.tsx index 11c281768..de45540c8 100644 --- a/components/modals/FundingTimelineModal.tsx +++ b/components/modals/FundingTimelineModal.tsx @@ -1,17 +1,89 @@ import { Dialog, Transition } from '@headlessui/react'; -import { Fragment } from 'react'; -import { Pencil, FileText, Rocket } from 'lucide-react'; +import { Fragment, useRef, useState } from 'react'; +import { Pencil, FileText, Rocket, Loader2, Upload } from 'lucide-react'; import { Button } from '@/components/ui/Button'; +import { detectImportFormat } from '@/components/Editor/lib/convert'; interface FundingTimelineModalProps { isOpen: boolean; onClose: () => void; + /** + * Called when the user picks "Start from template". The parent is + * responsible for creating the proposal note from the proposal template + * and navigating to it. + */ + onStartFromTemplate?: () => void | Promise; + /** + * Called when the user picks "Upload a file" and selects a valid + * document. The parent is responsible for converting the file and + * creating the proposal note. + */ + onUploadFile?: (file: File) => void | Promise; + /** + * Disables both CTAs and shows a spinner. Use while the parent is + * creating/converting the note. + */ + isProcessing?: boolean; } -export const FundingTimelineModal: React.FC = ({ isOpen, onClose }) => { +// File-picker accept hint. Matches the canonical list in `NoteCreationModal` +// so the proposal upload flow accepts the exact same formats as the sidebar. +const ACCEPT_ATTR = [ + '.docx', + '.odt', + '.md', + '.markdown', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'application/vnd.oasis.opendocument.text', + 'text/markdown', +].join(','); + +const MAX_FILE_SIZE = 25 * 1024 * 1024; + +export const FundingTimelineModal: React.FC = ({ + isOpen, + onClose, + onStartFromTemplate, + onUploadFile, + isProcessing = false, +}) => { + const fileInputRef = useRef(null); + const [fileError, setFileError] = useState(null); + + const openFilePicker = () => { + setFileError(null); + fileInputRef.current?.click(); + }; + + const handleFileChange = (event: React.ChangeEvent) => { + const picked = event.target.files?.[0] ?? null; + event.target.value = ''; // allow re-selecting the same file + if (!picked) return; + + if (!detectImportFormat(picked)) { + setFileError('Only .docx, .odt, and .md files are supported.'); + return; + } + if (picked.size > MAX_FILE_SIZE) { + setFileError('That file is larger than 25 MB. Try a smaller document.'); + return; + } + + setFileError(null); + void onUploadFile?.(picked); + }; + + // The modal is dismissable by ESC/backdrop click only while the parent + // isn't mid-creation — otherwise the user could close it while a note is + // being conjured up in the background, leaving them on an unhelpful page. + const handleDialogClose = () => { + if (isProcessing) return; + onClose(); + }; + return ( - + = ({ isOp Add authors and topics in the sidebar. Include the funding goal from your document.

- {/*
- - ✨ Boost your campaign by uploading a research image for NFT rewards - -
*/} @@ -114,11 +181,51 @@ export const FundingTimelineModal: React.FC = ({ isOp -
- +
+ {fileError && ( +

+ {fileError} +

+ )} + {isProcessing && ( +

+ Preparing your proposal — this can take a few seconds... +

+ )} +
+ + +
+ +
From f12257f74b49f67b674ed96d3149e97ea334ba61 Mon Sep 17 00:00:00 2001 From: Kobe Attias Date: Sun, 24 May 2026 22:11:40 -0400 Subject: [PATCH 3/5] Turn into submenu --- .../menus/ContentItemMenu/ContentItemMenu.tsx | 128 ++++++++++++------ .../hooks/useContentItemActions.tsx | 23 ++++ .../components/ui/Dropdown/Dropdown.tsx | 53 ++++---- .../Editor/extensions/SlashCommand/groups.ts | 44 ++++++ .../Editor/extensions/SlashCommand/types.ts | 11 ++ 5 files changed, 195 insertions(+), 64 deletions(-) diff --git a/components/Editor/components/menus/ContentItemMenu/ContentItemMenu.tsx b/components/Editor/components/menus/ContentItemMenu/ContentItemMenu.tsx index 7f6aa624a..750ee635e 100644 --- a/components/Editor/components/menus/ContentItemMenu/ContentItemMenu.tsx +++ b/components/Editor/components/menus/ContentItemMenu/ContentItemMenu.tsx @@ -3,12 +3,15 @@ import { Toolbar } from '@/components/Editor/components/ui/Toolbar'; import DragHandle from '@tiptap-pro/extension-drag-handle-react'; import { Editor } from '@tiptap/react'; -import * as Popover from '@radix-ui/react-popover'; +import * as DropdownMenu from '@radix-ui/react-dropdown-menu'; import { Surface } from '@/components/Editor/components/ui/Surface'; import { DropdownButton } from '@/components/Editor/components/ui/Dropdown'; +import { ChevronRight } from 'lucide-react'; +import GROUPS from '@/components/Editor/extensions/SlashCommand/groups'; +import { Command } from '@/components/Editor/extensions/SlashCommand/types'; import useContentItemActions from './hooks/useContentItemActions'; import { useData } from './hooks/useData'; -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; export type ContentItemMenuProps = { editor: Editor; @@ -27,6 +30,22 @@ export const ContentItemMenu = ({ editor }: ContentItemMenuProps) => { } }, [editor, menuOpen]); + // Filter the shared slash-command GROUPS down to the items that make + // sense inside the "Turn into" submenu. Recomputed when the menu opens + // so `shouldBeHidden(editor)` reflects the current editor state (e.g. + // hide code block when the cursor is inside a columns layout). + const turnIntoGroups = useMemo(() => { + if (!menuOpen) return []; + return GROUPS.map((group) => ({ + ...group, + commands: group.commands.filter((command) => { + if (command.hideFromTurnInto) return false; + if (command.shouldBeHidden?.(editor)) return false; + return true; + }), + })).filter((group) => group.commands.length > 0); + }, [editor, menuOpen]); + return ( { - - + + - - - - - - - Clear formatting - - - - - - Copy to clipboard - - - - - - Duplicate - - - - - - - Delete - - - - - + + + + + + + + + Turn into + + + + + + + {turnIntoGroups.map((group) => ( +
+
+ {group.title} +
+ {group.commands.map((command: Command) => ( + actions.turnInto(command)} + > + + + {command.label} + + + ))} +
+ ))} +
+
+
+
+ + + + Clear formatting + + + + + + Copy to clipboard + + + + + + Duplicate + + + + + + + Delete + + +
+
+
+
); diff --git a/components/Editor/components/menus/ContentItemMenu/hooks/useContentItemActions.tsx b/components/Editor/components/menus/ContentItemMenu/hooks/useContentItemActions.tsx index 5707041c7..53d726a41 100644 --- a/components/Editor/components/menus/ContentItemMenu/hooks/useContentItemActions.tsx +++ b/components/Editor/components/menus/ContentItemMenu/hooks/useContentItemActions.tsx @@ -2,6 +2,7 @@ import { Node } from '@tiptap/pm/model'; import { NodeSelection } from '@tiptap/pm/state'; import { Editor } from '@tiptap/react'; import { useCallback } from 'react'; +import { Command } from '@/components/Editor/extensions/SlashCommand/types'; const useContentItemActions = ( editor: Editor, @@ -79,12 +80,34 @@ const useContentItemActions = ( } }, [currentNode, currentNodePos, editor]); + const turnInto = useCallback( + (command: Command) => { + if (currentNodePos === -1) return; + + if (command.convertAction) { + editor.chain().setMeta('hideDragHandle', true).setNodeSelection(currentNodePos).run(); + command.convertAction(editor); + return; + } + + editor + .chain() + .setMeta('hideDragHandle', true) + .setNodeSelection(currentNodePos) + .deleteSelection() + .run(); + command.action(editor); + }, + [editor, currentNodePos] + ); + return { resetTextFormatting, duplicateNode, copyNodeToClipboard, deleteNode, handleAdd, + turnInto, }; }; diff --git a/components/Editor/components/ui/Dropdown/Dropdown.tsx b/components/Editor/components/ui/Dropdown/Dropdown.tsx index cc1513c52..1c2548248 100644 --- a/components/Editor/components/ui/Dropdown/Dropdown.tsx +++ b/components/Editor/components/ui/Dropdown/Dropdown.tsx @@ -9,30 +9,31 @@ export const DropdownCategoryTitle = ({ children }: { children: React.ReactNode ); }; -export const DropdownButton = React.forwardRef< - HTMLButtonElement, - { - children: React.ReactNode; - isActive?: boolean; - onClick?: () => void; - disabled?: boolean; - className?: string; - } ->(function DropdownButtonInner({ children, isActive, onClick, disabled, className }, ref) { - const buttonClass = cn( - 'flex items-center gap-2 p-1.5 text-sm font-medium text-neutral-500 dark:text-neutral-400 text-left bg-transparent w-full rounded', - !isActive && !disabled, - 'hover:bg-neutral-100 hover:text-neutral-800 dark:hover:bg-neutral-900 dark:hover:text-neutral-200', - isActive && - !disabled && - 'bg-neutral-100 text-neutral-800 dark:bg-neutral-900 dark:text-neutral-200', - disabled && 'text-neutral-400 cursor-not-allowed dark:text-neutral-600', - className - ); +type DropdownButtonProps = React.ButtonHTMLAttributes & { + isActive?: boolean; +}; - return ( - - ); -}); +export const DropdownButton = React.forwardRef( + function DropdownButtonInner({ children, isActive, disabled, className, ...rest }, ref) { + const buttonClass = cn( + 'flex items-center gap-2 p-1.5 text-sm font-medium text-neutral-500 dark:text-neutral-400 text-left bg-transparent w-full rounded', + !isActive && !disabled, + 'hover:bg-neutral-100 hover:text-neutral-800 dark:hover:bg-neutral-900 dark:hover:text-neutral-200', + isActive && + !disabled && + 'bg-neutral-100 text-neutral-800 dark:bg-neutral-900 dark:text-neutral-200', + disabled && 'text-neutral-400 cursor-not-allowed dark:text-neutral-600', + className + ); + + // Spread `rest` so that Radix-injected props (onPointerMove, + // onPointerLeave, onKeyDown, data-state, aria-*, id) reach the + // underlying + ); + } +); diff --git a/components/Editor/extensions/SlashCommand/groups.ts b/components/Editor/extensions/SlashCommand/groups.ts index d5744fc2d..d47426dba 100644 --- a/components/Editor/extensions/SlashCommand/groups.ts +++ b/components/Editor/extensions/SlashCommand/groups.ts @@ -27,6 +27,19 @@ export const GROUPS: Group[] = [ name: 'format', title: 'Format', commands: [ + { + name: 'paragraph', + label: 'Paragraph', + iconName: 'Pilcrow', + description: 'Plain text paragraph', + aliases: ['p', 'text'], + action: (editor) => { + editor.chain().focus().setParagraph().run(); + }, + convertAction: (editor) => { + editor.chain().focus().setParagraph().run(); + }, + }, { name: 'heading1', label: 'Heading 1', @@ -36,6 +49,9 @@ export const GROUPS: Group[] = [ action: (editor) => { editor.chain().focus().setHeading({ level: 1 }).run(); }, + convertAction: (editor) => { + editor.chain().focus().setHeading({ level: 1 }).run(); + }, }, { name: 'heading2', @@ -46,6 +62,9 @@ export const GROUPS: Group[] = [ action: (editor) => { editor.chain().focus().setHeading({ level: 2 }).run(); }, + convertAction: (editor) => { + editor.chain().focus().setHeading({ level: 2 }).run(); + }, }, { name: 'heading3', @@ -56,6 +75,9 @@ export const GROUPS: Group[] = [ action: (editor) => { editor.chain().focus().setHeading({ level: 3 }).run(); }, + convertAction: (editor) => { + editor.chain().focus().setHeading({ level: 3 }).run(); + }, }, { name: 'bulletList', @@ -66,6 +88,9 @@ export const GROUPS: Group[] = [ action: (editor) => { editor.chain().focus().toggleBulletList().run(); }, + convertAction: (editor) => { + editor.chain().focus().toggleBulletList().run(); + }, }, { name: 'numberedList', @@ -76,6 +101,9 @@ export const GROUPS: Group[] = [ action: (editor) => { editor.chain().focus().toggleOrderedList().run(); }, + convertAction: (editor) => { + editor.chain().focus().toggleOrderedList().run(); + }, }, { name: 'taskList', @@ -86,6 +114,9 @@ export const GROUPS: Group[] = [ action: (editor) => { editor.chain().focus().toggleTaskList().run(); }, + convertAction: (editor) => { + editor.chain().focus().toggleTaskList().run(); + }, }, { name: 'toggleList', @@ -96,6 +127,9 @@ export const GROUPS: Group[] = [ action: (editor) => { editor.chain().focus().setDetails().run(); }, + convertAction: (editor) => { + editor.chain().focus().setDetails().run(); + }, }, { name: 'blockquote', @@ -105,6 +139,9 @@ export const GROUPS: Group[] = [ action: (editor) => { editor.chain().focus().setBlockquote().run(); }, + convertAction: (editor) => { + editor.chain().focus().setBlockquote().run(); + }, }, { name: 'codeBlock', @@ -115,6 +152,9 @@ export const GROUPS: Group[] = [ action: (editor) => { editor.chain().focus().setCodeBlock().run(); }, + convertAction: (editor) => { + editor.chain().focus().setCodeBlock().run(); + }, }, ], }, @@ -185,6 +225,10 @@ export const GROUPS: Group[] = [ iconName: 'Calculator', aliases: ['math', 'equation', 'latex'], description: 'Insert an inline math equation', + // Inline math is inline-level content; it can't replace a block + // node, so hide it from the "Turn into" menu while keeping it + // available from the slash menu and the `+` button. + hideFromTurnInto: true, action: (editor) => { editor .chain() diff --git a/components/Editor/extensions/SlashCommand/types.ts b/components/Editor/extensions/SlashCommand/types.ts index 11a973eb4..f42234916 100644 --- a/components/Editor/extensions/SlashCommand/types.ts +++ b/components/Editor/extensions/SlashCommand/types.ts @@ -14,8 +14,19 @@ export interface Command { description: string; aliases?: string[]; iconName: keyof typeof icons; + /** + * Run by the slash-command picker and the `+` insert button — inserts a + * new node at the cursor or replaces an empty paragraph. Always defined. + */ action: (editor: Editor) => void; + convertAction?: (editor: Editor) => void; shouldBeHidden?: (editor: Editor) => boolean; + /** + * Optional. Hide this command from the "Turn into" submenu specifically. + * Use for items where a block-level conversion makes no semantic sense + * (e.g. inline math is inline-level, not a block). + */ + hideFromTurnInto?: boolean; } export interface MenuListProps { From df7f73cfa7e0a94b0a9b0284d8c15dffbfcf628e Mon Sep 17 00:00:00 2001 From: Kobe Attias Date: Sun, 24 May 2026 22:27:15 -0400 Subject: [PATCH 4/5] Remove hover effect around document option --- components/Editor/styles/index.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/Editor/styles/index.css b/components/Editor/styles/index.css index b5437bcff..d2df59433 100644 --- a/components/Editor/styles/index.css +++ b/components/Editor/styles/index.css @@ -9,7 +9,7 @@ @import './partials/readonly.css'; .ProseMirror { - @apply caret-black dark:caret-white outline-0 pr-8 pl-20 py-16 z-0 lg:pl-8 lg:pr-8 mx-auto max-w-4xl; + @apply caret-black dark:caret-white outline-none pr-8 pl-20 py-16 z-0 lg:pl-8 lg:pr-8 mx-auto max-w-4xl; .selection { @apply inline; From 86c6036a2b9decadc6b68c4dfa38b9f713eb2e4a Mon Sep 17 00:00:00 2001 From: Kobe Attias Date: Sun, 24 May 2026 22:37:26 -0400 Subject: [PATCH 5/5] Spacing in notebook turn into menu --- .../components/menus/ContentItemMenu/ContentItemMenu.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/components/Editor/components/menus/ContentItemMenu/ContentItemMenu.tsx b/components/Editor/components/menus/ContentItemMenu/ContentItemMenu.tsx index 750ee635e..0d6bb4d71 100644 --- a/components/Editor/components/menus/ContentItemMenu/ContentItemMenu.tsx +++ b/components/Editor/components/menus/ContentItemMenu/ContentItemMenu.tsx @@ -11,7 +11,7 @@ import GROUPS from '@/components/Editor/extensions/SlashCommand/groups'; import { Command } from '@/components/Editor/extensions/SlashCommand/types'; import useContentItemActions from './hooks/useContentItemActions'; import { useData } from './hooks/useData'; -import { useEffect, useMemo, useState } from 'react'; +import { Fragment, useEffect, useMemo, useState } from 'react'; export type ContentItemMenuProps = { editor: Editor; @@ -81,8 +81,8 @@ export const ContentItemMenu = ({ editor }: ContentItemMenuProps) => { {turnIntoGroups.map((group) => ( -
-
+ +
{group.title}
{group.commands.map((command: Command) => ( @@ -97,7 +97,7 @@ export const ContentItemMenu = ({ editor }: ContentItemMenuProps) => { ))} -
+ ))}