diff --git a/apps/web/src/components/EditorPane.tsx b/apps/web/src/components/EditorPane.tsx index 3093af15..d333ac26 100644 --- a/apps/web/src/components/EditorPane.tsx +++ b/apps/web/src/components/EditorPane.tsx @@ -95,6 +95,15 @@ const MarkdownSourceEditor = lazy(() => import { sanitizeAndScopeCss } from "@/lib/css-sandbox"; import { RevisionHistoryDialog } from "./dialogs/RevisionHistoryDialog"; import { ExternalLinkDialog } from "./dialogs/ExternalLinkDialog"; +import { MathFormulaDialog } from "./dialogs/MathFormulaDialog"; +import { EditorBlockDragHandle } from "./editor/EditorBlockDragHandle"; +import { + applyMathFormula, + deleteMathFormula, + resolveMathFormulaTarget, + selectedTextAsLatex, + type MathFormulaDraft, +} from "./editor/math-formula"; import { memoShareQueryKey, ShareMemoDialog } from "./dialogs/ShareMemoDialog"; import { ShareNoteImageDialog } from "./dialogs/ShareNoteImageDialog"; import { AiAssistantDialog, type AiAssistantAnchor } from "./dialogs/AiAssistantDialog"; @@ -255,6 +264,7 @@ import { getNoteLinkFromEventTarget, getNoteLinkHintPosition, getResourceFilesFromDataTransfer, + shouldInsertDroppedResourceFiles, isCreatedMemoEditorFocused, isEditorReady, MemoSaveRequestError, @@ -446,6 +456,8 @@ const RichEditorPane = ({ showTextField: true, canRemove: false, }); + const [mathFormulaOpen, setMathFormulaOpen] = useState(false); + const [mathFormulaDraft, setMathFormulaDraft] = useState(null); const { menuTarget: resourceMenuTarget, dialog: resourceDialog, @@ -575,6 +587,7 @@ const RichEditorPane = ({ setAiInsertionTarget(null); closeNoteReplaceRef.current(); setExternalLinkDialogOpen(false); + setMathFormulaOpen(false); setNoteLinkPickerOpen(false); }, [desktopReadingProtection]); @@ -590,6 +603,12 @@ const RichEditorPane = ({ const mobileSaveTimerRef = useRef(null); const fileInputRef = useRef(null); const openExternalLinkDialogRef = useRef<() => void>(() => undefined); + const openMathFormulaRef = useRef<(kind?: "inline" | "block") => void>(() => undefined); + const mathClickRef = useRef<(node: { attrs: Record }, pos: number, kind: "inline" | "block") => void>(() => undefined); + const mathematicsExtensionsRef = useRef(createEdgeEverMathematics({ + onInlineClick: (node, pos) => mathClickRef.current(node, pos, "inline"), + onBlockClick: (node, pos) => mathClickRef.current(node, pos, "block"), + })); const slashCommandLabelsRef = useRef({ menu: "", empty: "", @@ -612,6 +631,8 @@ const RichEditorPane = ({ divider: "", fold: "", table: "", + "inline-math": "", + "block-math": "", "current-date": "", "current-time": "", "current-date-time": "", @@ -646,6 +667,8 @@ const RichEditorPane = ({ divider: t("editorToolbar.horizontalRule"), fold: t("editorToolbar.fold"), table: t("editorToolbar.table"), + "inline-math": t("editorToolbar.math"), + "block-math": t("editorToolbar.blockMath"), "current-date": t("slashMenu.items.currentDate"), "current-time": t("slashMenu.items.currentTime"), "current-date-time": t("slashMenu.items.currentDateTime"), @@ -661,6 +684,7 @@ const RichEditorPane = ({ openAttachmentPicker: () => fileInputRef.current?.click(), openExternalLinkPicker: () => openExternalLinkDialogRef.current(), openNoteLinkPicker: () => setNoteLinkPickerOpen(true), + openMathFormula: (kind) => openMathFormulaRef.current(kind), }; } const slashCommandExtensionRef = useRef | null>(null); @@ -1064,8 +1088,12 @@ const RichEditorPane = ({ const editor = useEditor({ extensions: [ ...createEdgeEverDocumentExtensions({ - mathematics: createEdgeEverMathematics(), - starterKit: { codeBlock: false, link: false }, + mathematics: mathematicsExtensionsRef.current, + starterKit: { + codeBlock: false, + link: false, + dropcursor: { color: "#16A06E", width: 2 }, + }, image: false, gallery: EditableImageGallery, pdf: PdfAttachment, @@ -1215,15 +1243,16 @@ const RichEditorPane = ({ insertResourceFiles(files); return true; }, - handleDrop: (_view, event) => { - const files = getResourceFilesFromDataTransfer(event.dataTransfer); - - if (files.length === 0) { + handleDrop: (view, event) => { + if (!shouldInsertDroppedResourceFiles({ + dataTransfer: event.dataTransfer, + isInternalNodeDrag: Boolean(view.dragging), + })) { return false; } event.preventDefault(); - insertResourceFiles(files); + insertResourceFiles(getResourceFilesFromDataTransfer(event.dataTransfer)); return true; }, }, @@ -1350,6 +1379,42 @@ const RichEditorPane = ({ openExternalLinkDialogRef.current = openExternalLinkDialog; + const openMathFormula = useCallback((kind: "inline" | "block" = "inline") => { + if (effectiveReadOnly || !isEditorReady(editor) || useMarkdownSourceEditor || useMobilePlainTextEditor) { + return; + } + + const existing = resolveMathFormulaTarget(editor); + if (existing) { + setMathFormulaDraft(existing); + setMathFormulaOpen(true); + return; + } + + const { from, to } = editor.state.selection; + setMathFormulaDraft({ + kind, + latex: selectedTextAsLatex(editor), + from, + to, + }); + setMathFormulaOpen(true); + }, [editor, effectiveReadOnly, useMarkdownSourceEditor, useMobilePlainTextEditor]); + + openMathFormulaRef.current = openMathFormula; + mathClickRef.current = (_node, pos, kind) => { + if (effectiveReadOnly || !isEditorReady(editor) || useMarkdownSourceEditor || useMobilePlainTextEditor) { + return; + } + const existing = resolveMathFormulaTarget(editor, pos); + setMathFormulaDraft(existing ?? { + kind, + latex: String(_node.attrs.latex ?? ""), + pos, + }); + setMathFormulaOpen(true); + }; + const applyExternalLink = useCallback( ({ href, text }: { href: string; text: string }) => { if (effectiveReadOnly) { @@ -3396,6 +3461,25 @@ const RichEditorPane = ({ onApply={applyExternalLink} onRemove={removeExternalLink} /> + { + if (!isEditorReady(editor) || effectiveReadOnly) return; + editor.commands.focus(); + applyMathFormula(editor, draft); + }} + onRemove={ + mathFormulaDraft && typeof mathFormulaDraft.pos === "number" + ? () => { + if (!isEditorReady(editor) || effectiveReadOnly) return; + editor.commands.focus(); + deleteMathFormula(editor, { kind: mathFormulaDraft.kind, pos: mathFormulaDraft.pos as number }); + } + : undefined + } + /> {noteLinkPickerOpen && ( setNoteLinkPickerOpen(true)} + onPickMathFormula={() => openMathFormula()} /> )} ) : (
+ {!isMobileViewport && !effectiveReadOnly && isEditorReady(editor) ? ( + + ) : null}
)} diff --git a/apps/web/src/components/EditorToolbar.tsx b/apps/web/src/components/EditorToolbar.tsx index 029a8e95..7ba2d415 100644 --- a/apps/web/src/components/EditorToolbar.tsx +++ b/apps/web/src/components/EditorToolbar.tsx @@ -18,6 +18,7 @@ import { Paperclip, Link, Link2, + Sigma, ChevronDown, ChevronUp, } from "lucide-react"; @@ -155,6 +156,7 @@ export const EditorToolbar = ({ onPickAttachment, onPickExternalLink, onPickNoteLink, + onPickMathFormula, externalLinkActive = false, }: { editor: Editor | null; @@ -166,6 +168,7 @@ export const EditorToolbar = ({ /** Insert or edit an external hyperlink (not a note reference). */ onPickExternalLink?: () => void; onPickNoteLink?: () => void; + onPickMathFormula?: () => void; externalLinkActive?: boolean; }) => { const { t } = useTranslation(); @@ -564,6 +567,16 @@ export const EditorToolbar = ({ > + {onPickMathFormula && ( + + + + )} { + const trimmed = latex.trim(); + if (!trimmed) return ""; + try { + return katex.renderToString(trimmed, { + displayMode: kind === "block", + throwOnError: false, + strict: "warn", + trust: false, + }); + } catch { + return ""; + } +}; + +export const MathFormulaDialog = ({ + open, + draft, + onOpenChange, + onApply, + onRemove, +}: { + open: boolean; + draft: MathFormulaDraft | null; + onOpenChange: (open: boolean) => void; + onApply: (draft: MathFormulaDraft) => void; + onRemove?: () => void; +}) => { + const { t } = useTranslation(); + const latexId = useId(); + const previewId = useId(); + const latexInputRef = useRef(null); + const [kind, setKind] = useState(draft?.kind ?? "inline"); + const [latex, setLatex] = useState(draft?.latex ?? ""); + const [error, setError] = useState(null); + const editing = typeof draft?.pos === "number"; + + useEffect(() => { + if (!open) return; + setKind(draft?.kind ?? "inline"); + setLatex(draft?.latex ?? ""); + setError(null); + const timer = window.setTimeout(() => { + latexInputRef.current?.focus(); + latexInputRef.current?.select(); + }, 0); + return () => window.clearTimeout(timer); + }, [draft, open]); + + const previewHtml = useMemo(() => renderPreviewHtml(latex, kind), [kind, latex]); + + const submit = () => { + if (!latex.trim()) { + setError(t("mathFormulaDialog.errorEmpty")); + return; + } + onApply({ + kind, + latex, + pos: draft?.pos, + from: draft?.from, + to: draft?.to, + }); + onOpenChange(false); + }; + + return ( + + + + + + {editing ? t("mathFormulaDialog.editTitle") : t("mathFormulaDialog.title")} + + + {t("mathFormulaDialog.description")} + + + +
{ + event.preventDefault(); + submit(); + }} + > + {!editing && ( +
+ {(["inline", "block"] as const).map((value) => ( + + ))} +
+ )} + +
+ +