feat(web): add block drag handles and formula insert
Let users reorder blocks from the desktop rich editor, and insert or edit LaTeX from the toolbar, slash menu, or by clicking a formula.
This commit is contained in:
1 parent
9ddb3466bc
commit
5880699347
17 files changed
+722
-14
No files matched your search
@@ -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<MathFormulaDraft | null>(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<number | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const openExternalLinkDialogRef = useRef<() => void>(() => undefined);
|
||||
const openMathFormulaRef = useRef<(kind?: "inline" | "block") => void>(() => undefined);
|
||||
const mathClickRef = useRef<(node: { attrs: Record<string, unknown> }, 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<SlashCommandLabels>({
|
||||
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<ReturnType<typeof createSlashCommandExtension> | 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}
|
||||
/>
|
||||
<MathFormulaDialog
|
||||
open={mathFormulaOpen}
|
||||
draft={mathFormulaDraft}
|
||||
onOpenChange={setMathFormulaOpen}
|
||||
onApply={(draft) => {
|
||||
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 && (
|
||||
<EditorNoteLinkPicker
|
||||
query={noteLinkQuery}
|
||||
@@ -3818,6 +3902,7 @@ const RichEditorPane = ({
|
||||
onPickExternalLink={openExternalLinkDialog}
|
||||
externalLinkActive={externalLinkActive}
|
||||
onPickNoteLink={() => setNoteLinkPickerOpen(true)}
|
||||
onPickMathFormula={() => openMathFormula()}
|
||||
/>
|
||||
)}
|
||||
<EditorSaveRecoveryBanner
|
||||
@@ -3983,6 +4068,7 @@ const RichEditorPane = ({
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="relative"
|
||||
onMouseOver={handleEditorMouseOver}
|
||||
onMouseOut={handleEditorMouseOut}
|
||||
onFocusCapture={handleEditorFocusCapture}
|
||||
@@ -4005,6 +4091,9 @@ const RichEditorPane = ({
|
||||
{t("aiAssistant.openForSelection")}
|
||||
</Button>
|
||||
</BubbleMenu>
|
||||
{!isMobileViewport && !effectiveReadOnly && isEditorReady(editor) ? (
|
||||
<EditorBlockDragHandle editor={editor} />
|
||||
) : null}
|
||||
<EditorContent editor={editor} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -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 = ({
|
||||
>
|
||||
<ChartNoAxesCombined className="h-4 w-4" />
|
||||
</EditorToolbarButton>
|
||||
{onPickMathFormula && (
|
||||
<EditorToolbarButton
|
||||
title={t("editorToolbar.math")}
|
||||
active={isActive("inlineMath") || isActive("blockMath")}
|
||||
disabled={disabled}
|
||||
onClick={onPickMathFormula}
|
||||
>
|
||||
<Sigma className="h-4 w-4" />
|
||||
</EditorToolbarButton>
|
||||
)}
|
||||
<EditorToolbarButton
|
||||
title={t("editorToolbar.horizontalRule")}
|
||||
disabled={disabled}
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
import { useEffect, useId, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import katex from "katex";
|
||||
import { Sigma } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import type { MathFormulaDraft, MathFormulaKind } from "@/components/editor/math-formula";
|
||||
|
||||
const renderPreviewHtml = (latex: string, kind: MathFormulaKind) => {
|
||||
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<HTMLTextAreaElement>(null);
|
||||
const [kind, setKind] = useState<MathFormulaKind>(draft?.kind ?? "inline");
|
||||
const [latex, setLatex] = useState(draft?.latex ?? "");
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-lg gap-0 overflow-hidden p-0">
|
||||
<DialogHeader className="border-b border-slate-200 px-5 py-5 pr-12 text-left">
|
||||
<DialogTitle className="flex items-center gap-2 text-base">
|
||||
<Sigma className="h-5 w-5 text-emerald-600" />
|
||||
{editing ? t("mathFormulaDialog.editTitle") : t("mathFormulaDialog.title")}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="pt-1 leading-5">
|
||||
{t("mathFormulaDialog.description")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form
|
||||
className="space-y-4 px-5 py-4"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
submit();
|
||||
}}
|
||||
>
|
||||
{!editing && (
|
||||
<div className="flex rounded-md border border-slate-200 p-0.5">
|
||||
{(["inline", "block"] as const).map((value) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
className={
|
||||
kind === value
|
||||
? "flex-1 rounded-sm bg-emerald-50 px-3 py-1.5 text-xs font-medium text-emerald-800"
|
||||
: "flex-1 rounded-sm px-3 py-1.5 text-xs font-medium text-slate-600 hover:bg-slate-50"
|
||||
}
|
||||
aria-pressed={kind === value}
|
||||
onClick={() => setKind(value)}
|
||||
>
|
||||
{value === "inline" ? t("mathFormulaDialog.inline") : t("mathFormulaDialog.block")}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label htmlFor={latexId} className="text-xs font-medium text-slate-600">
|
||||
{t("mathFormulaDialog.latexLabel")}
|
||||
</label>
|
||||
<textarea
|
||||
ref={latexInputRef}
|
||||
id={latexId}
|
||||
value={latex}
|
||||
rows={3}
|
||||
spellCheck={false}
|
||||
className="w-full resize-y rounded-md border border-slate-200 bg-card px-3 py-2 font-mono text-sm text-slate-900 outline-none ring-emerald-500/30 placeholder:text-slate-400 focus:border-emerald-400 focus:ring-2"
|
||||
placeholder={t("mathFormulaDialog.latexPlaceholder")}
|
||||
aria-invalid={Boolean(error) || undefined}
|
||||
onChange={(event) => {
|
||||
setLatex(event.target.value);
|
||||
if (error) setError(null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<p id={previewId} className="text-xs font-medium text-slate-600">
|
||||
{t("mathFormulaDialog.preview")}
|
||||
</p>
|
||||
<div
|
||||
aria-labelledby={previewId}
|
||||
className={
|
||||
kind === "block"
|
||||
? "min-h-16 overflow-x-auto rounded-md border border-slate-200 bg-slate-50 px-3 py-4 text-center"
|
||||
: "min-h-12 overflow-x-auto rounded-md border border-slate-200 bg-slate-50 px-3 py-3"
|
||||
}
|
||||
>
|
||||
{latex.trim() ? (
|
||||
previewHtml ? (
|
||||
<span dangerouslySetInnerHTML={{ __html: previewHtml }} />
|
||||
) : (
|
||||
<p className="text-sm text-rose-700">{t("mathFormulaDialog.previewError")}</p>
|
||||
)
|
||||
) : (
|
||||
<p className="text-sm text-slate-400">{t("mathFormulaDialog.previewEmpty")}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="text-xs text-rose-600" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<DialogFooter className="gap-2 border-t border-slate-100 pt-4 sm:justify-between">
|
||||
<div className="flex min-w-0 flex-1 flex-wrap gap-2">
|
||||
{editing && onRemove && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="text-rose-700 hover:bg-rose-50 hover:text-rose-800"
|
||||
onClick={() => {
|
||||
onRemove();
|
||||
onOpenChange(false);
|
||||
}}
|
||||
>
|
||||
{t("mathFormulaDialog.remove")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
<Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button type="submit" variant="solid">
|
||||
{t("mathFormulaDialog.apply")}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useEffect } from "react";
|
||||
import type { Editor } from "@tiptap/react";
|
||||
import DragHandle from "@tiptap/extension-drag-handle-react";
|
||||
import { GripVertical } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const BLOCK_DRAG_HANDLE_CLASS_NAME = "edgeever-block-drag-handle";
|
||||
|
||||
const isBlockDragHandleEvent = (event: DragEvent) => {
|
||||
const target = event.target;
|
||||
return target instanceof Element && Boolean(target.closest(`.${BLOCK_DRAG_HANDLE_CLASS_NAME}`));
|
||||
};
|
||||
|
||||
/** TipTap clears dataTransfer during dragstart; Chrome then refuses the drop unless a type remains. */
|
||||
const retainBlockDragDataTransfer = (event: DragEvent) => {
|
||||
if (!isBlockDragHandleEvent(event) || !event.dataTransfer) {
|
||||
return;
|
||||
}
|
||||
event.dataTransfer.effectAllowed = "move";
|
||||
if (![...event.dataTransfer.types].includes("text/plain")) {
|
||||
event.dataTransfer.setData("text/plain", " ");
|
||||
}
|
||||
};
|
||||
|
||||
export const EditorBlockDragHandle = ({ editor }: { editor: Editor }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener("dragstart", retainBlockDragDataTransfer);
|
||||
return () => document.removeEventListener("dragstart", retainBlockDragDataTransfer);
|
||||
}, []);
|
||||
|
||||
if (editor.isDestroyed || !editor.isEditable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<DragHandle
|
||||
editor={editor}
|
||||
className={BLOCK_DRAG_HANDLE_CLASS_NAME}
|
||||
nested
|
||||
>
|
||||
<span className="sr-only">{t("editor.dragHandle")}</span>
|
||||
<GripVertical aria-hidden="true" className="h-4 w-4" />
|
||||
</DragHandle>
|
||||
);
|
||||
};
|
||||
@@ -29,6 +29,8 @@ const labels = {
|
||||
divider: "分割线",
|
||||
fold: "折叠块",
|
||||
table: "表格",
|
||||
"inline-math": "插入公式",
|
||||
"block-math": "块级公式",
|
||||
"current-date": "当前日期",
|
||||
"current-time": "当前时间",
|
||||
"current-date-time": "当前日期和时间",
|
||||
@@ -45,7 +47,7 @@ describe("slash command menu", () => {
|
||||
const unfilteredItems = filterSlashCommandItems(items, "");
|
||||
const heading6Index = unfilteredItems.findIndex((item) => item.id === "heading-6");
|
||||
|
||||
expect(unfilteredItems).toHaveLength(22);
|
||||
expect(unfilteredItems).toHaveLength(24);
|
||||
expect(unfilteredItems.slice(heading6Index + 1, heading6Index + 4).map((item) => item.id)).toEqual([
|
||||
"current-date",
|
||||
"current-time",
|
||||
@@ -83,6 +85,8 @@ describe("slash command menu", () => {
|
||||
"divider",
|
||||
"fold",
|
||||
"table",
|
||||
"math",
|
||||
"equation",
|
||||
"upload",
|
||||
"note",
|
||||
"link",
|
||||
@@ -93,6 +97,8 @@ describe("slash command menu", () => {
|
||||
expect(filterSlashCommandItems(items, "collapse").map((item) => item.id)).toEqual(["fold"]);
|
||||
expect(filterSlashCommandItems(items, "table").map((item) => item.id)).toEqual(["table"]);
|
||||
expect(filterSlashCommandItems(items, "task").map((item) => item.id)).toEqual(["task-list"]);
|
||||
expect(filterSlashCommandItems(items, "公式").map((item) => item.id)).toEqual(["inline-math", "block-math"]);
|
||||
expect(filterSlashCommandItems(items, "equation").map((item) => item.id)).toEqual(["block-math"]);
|
||||
});
|
||||
|
||||
test("formats local date and time as stable static text", () => {
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
ListTodo,
|
||||
Pilcrow,
|
||||
Quote,
|
||||
Sigma,
|
||||
Table2,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
@@ -53,6 +54,8 @@ export type SlashCommandId =
|
||||
| "divider"
|
||||
| "fold"
|
||||
| "table"
|
||||
| "inline-math"
|
||||
| "block-math"
|
||||
| "current-date"
|
||||
| "current-time"
|
||||
| "current-date-time"
|
||||
@@ -76,6 +79,7 @@ export type SlashCommandActions = {
|
||||
openAttachmentPicker: () => void;
|
||||
openExternalLinkPicker: () => void;
|
||||
openNoteLinkPicker: () => void;
|
||||
openMathFormula: (kind: "inline" | "block") => void;
|
||||
};
|
||||
|
||||
export type SlashCommandItem = {
|
||||
@@ -120,6 +124,8 @@ export const createSlashCommandItems = (labels: SlashCommandLabels): SlashComman
|
||||
{ id: "divider", command: "divider", group: "insert", icon: BetweenHorizontalStart, label: labels.items.divider, keywords: ["rule", "分割", "分隔"] },
|
||||
{ id: "fold", command: "fold", group: "insert", icon: ChevronsDownUp, label: labels.items.fold, keywords: ["collapse", "spoiler", "折叠", "折りたたみ"] },
|
||||
{ id: "table", command: "table", group: "insert", icon: Table2, label: labels.items.table, keywords: ["表格"] },
|
||||
{ id: "inline-math", command: "math", group: "insert", icon: Sigma, label: labels.items["inline-math"], keywords: ["latex", "formula", "katex", "公式", "数学", "数式"] },
|
||||
{ id: "block-math", command: "equation", group: "insert", icon: Sigma, label: labels.items["block-math"], keywords: ["latex", "formula", "display", "katex", "块级公式", "独立公式", "数式"] },
|
||||
{ id: "attachment", command: "upload", group: "insert", icon: FileUp, label: labels.items.attachment, keywords: ["file", "attachment", "文件", "上传", "附件"] },
|
||||
{ id: "note-link", command: "note", group: "insert", icon: Link, label: labels.items["note-link"], keywords: ["link", "memo", "笔记", "引用"] },
|
||||
{ id: "external-link", command: "link", group: "insert", icon: Link, label: labels.items["external-link"], keywords: ["url", "web", "链接", "网址"] },
|
||||
@@ -248,6 +254,14 @@ const runSlashCommand = ({
|
||||
case "divider": chain.setHorizontalRule().run(); break;
|
||||
case "fold": chain.setDetails().run(); break;
|
||||
case "table": chain.insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run(); break;
|
||||
case "inline-math":
|
||||
chain.run();
|
||||
window.requestAnimationFrame(() => actions.openMathFormula("inline"));
|
||||
break;
|
||||
case "block-math":
|
||||
chain.run();
|
||||
window.requestAnimationFrame(() => actions.openMathFormula("block"));
|
||||
break;
|
||||
case "current-date": chain.insertContent(formatCurrentDate(new Date())).run(); break;
|
||||
case "current-time": chain.insertContent(formatCurrentTime(new Date())).run(); break;
|
||||
case "current-date-time": chain.insertContent(formatCurrentDateTime(new Date())).run(); break;
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { shouldRetryCreatedMemoFocus } from "./editor-pane-helpers.ts";
|
||||
import {
|
||||
shouldInsertDroppedResourceFiles,
|
||||
shouldRetryCreatedMemoFocus,
|
||||
} from "./editor-pane-helpers.ts";
|
||||
|
||||
describe("created memo focus retry", () => {
|
||||
test("keeps retrying until the editor is hydrated, editable, and focused", () => {
|
||||
@@ -56,3 +59,32 @@ describe("editor document reset", () => {
|
||||
expect(source).toContain("plugins: editor.state.plugins");
|
||||
});
|
||||
});
|
||||
|
||||
describe("dropped resource files", () => {
|
||||
const fileTransfer = {
|
||||
items: [{ kind: "file", getAsFile: () => new File(["png"], "preview.png", { type: "image/png" }) }],
|
||||
files: [],
|
||||
};
|
||||
|
||||
test("inserts clipboard or OS files", () => {
|
||||
expect(shouldInsertDroppedResourceFiles({
|
||||
dataTransfer: fileTransfer,
|
||||
isInternalNodeDrag: false,
|
||||
})).toBe(true);
|
||||
});
|
||||
|
||||
test("does not steal a block-handle drop that Chrome tagged with a preview file", () => {
|
||||
expect(shouldInsertDroppedResourceFiles({
|
||||
dataTransfer: fileTransfer,
|
||||
isInternalNodeDrag: true,
|
||||
})).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("block handle drop wiring", () => {
|
||||
test("lets ProseMirror finish an internal node drag", () => {
|
||||
const source = readFileSync(new URL("../EditorPane.tsx", import.meta.url), "utf8");
|
||||
expect(source).toContain("shouldInsertDroppedResourceFiles");
|
||||
expect(source).toContain("isInternalNodeDrag: Boolean(view.dragging)");
|
||||
});
|
||||
});
|
||||
@@ -188,6 +188,15 @@ export const getResourceFilesFromDataTransfer = (dataTransfer: DataTransfer | nu
|
||||
return files.filter((file) => file.size > 0);
|
||||
};
|
||||
|
||||
/** Block-handle drags set view.dragging; Chrome may also attach a preview image as a file. */
|
||||
export const shouldInsertDroppedResourceFiles = ({
|
||||
dataTransfer,
|
||||
isInternalNodeDrag,
|
||||
}: {
|
||||
dataTransfer: DataTransfer | null;
|
||||
isInternalNodeDrag: boolean;
|
||||
}) => !isInternalNodeDrag && getResourceFilesFromDataTransfer(dataTransfer).length > 0;
|
||||
|
||||
export const syncStatusToSaveState = (status: "pending" | "syncing" | "conflict" | "error") => {
|
||||
if (status === "conflict") {
|
||||
return "conflict";
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { Editor } from "@tiptap/core";
|
||||
import { createEdgeEverDocumentExtensions } from "@edgeever/shared";
|
||||
import { createEdgeEverMathematics } from "@edgeever/shared/mathematics";
|
||||
import {
|
||||
applyMathFormula,
|
||||
deleteMathFormula,
|
||||
resolveMathFormulaTarget,
|
||||
selectedTextAsLatex,
|
||||
} from "./math-formula.ts";
|
||||
|
||||
const createEditor = (content) => new Editor({
|
||||
extensions: createEdgeEverDocumentExtensions({
|
||||
mathematics: createEdgeEverMathematics(),
|
||||
}),
|
||||
content,
|
||||
});
|
||||
|
||||
describe("math formula commands", () => {
|
||||
test("inserts inline math at the caret", () => {
|
||||
const editor = createEditor({
|
||||
type: "doc",
|
||||
content: [{ type: "paragraph", content: [{ type: "text", text: "ab" }] }],
|
||||
});
|
||||
editor.commands.setTextSelection(2);
|
||||
expect(applyMathFormula(editor, { kind: "inline", latex: "x^2" })).toBe(true);
|
||||
expect(editor.getJSON().content[0].content).toEqual([
|
||||
{ type: "text", text: "a" },
|
||||
{ type: "inlineMath", attrs: { latex: "x^2" } },
|
||||
{ type: "text", text: "b" },
|
||||
]);
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
test("replaces the selected text with inline math", () => {
|
||||
const editor = createEditor({
|
||||
type: "doc",
|
||||
content: [{ type: "paragraph", content: [{ type: "text", text: "E=mc^2" }] }],
|
||||
});
|
||||
editor.commands.setTextSelection({ from: 1, to: 7 });
|
||||
expect(selectedTextAsLatex(editor)).toBe("E=mc^2");
|
||||
expect(applyMathFormula(editor, {
|
||||
kind: "inline",
|
||||
latex: "E=mc^2",
|
||||
from: 1,
|
||||
to: 7,
|
||||
})).toBe(true);
|
||||
expect(editor.getJSON().content[0].content).toEqual([
|
||||
{ type: "inlineMath", attrs: { latex: "E=mc^2" } },
|
||||
]);
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
test("updates and deletes an existing formula", () => {
|
||||
const editor = createEditor({
|
||||
type: "doc",
|
||||
content: [{
|
||||
type: "paragraph",
|
||||
content: [{ type: "inlineMath", attrs: { latex: "a+b" } }],
|
||||
}],
|
||||
});
|
||||
editor.commands.setTextSelection(1);
|
||||
const target = resolveMathFormulaTarget(editor, 1);
|
||||
expect(target).toEqual({ kind: "inline", pos: 1, latex: "a+b" });
|
||||
expect(applyMathFormula(editor, { kind: "inline", latex: "c+d", pos: 1 })).toBe(true);
|
||||
expect(editor.getJSON().content[0].content[0].attrs.latex).toBe("c+d");
|
||||
expect(deleteMathFormula(editor, { kind: "inline", pos: 1 })).toBe(true);
|
||||
expect(editor.getJSON().content[0].content ?? []).toEqual([]);
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
test("inserts a display formula as its own block", () => {
|
||||
const editor = createEditor({
|
||||
type: "doc",
|
||||
content: [{ type: "paragraph" }],
|
||||
});
|
||||
expect(applyMathFormula(editor, { kind: "block", latex: "\\int x dx" })).toBe(true);
|
||||
expect(editor.getJSON().content.some((node) => node.type === "blockMath" && node.attrs.latex === "\\int x dx")).toBe(true);
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
test("rejects empty latex", () => {
|
||||
const editor = createEditor({
|
||||
type: "doc",
|
||||
content: [{ type: "paragraph" }],
|
||||
});
|
||||
expect(applyMathFormula(editor, { kind: "inline", latex: " " })).toBe(false);
|
||||
editor.destroy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { Editor } from "@tiptap/core";
|
||||
import {
|
||||
BLOCK_MATH_NODE_TYPE,
|
||||
INLINE_MATH_NODE_TYPE,
|
||||
} from "@edgeever/shared/mathematics";
|
||||
|
||||
export type MathFormulaKind = "inline" | "block";
|
||||
|
||||
export type MathFormulaTarget = {
|
||||
kind: MathFormulaKind;
|
||||
latex: string;
|
||||
pos: number;
|
||||
};
|
||||
|
||||
export type MathFormulaDraft = {
|
||||
kind: MathFormulaKind;
|
||||
latex: string;
|
||||
pos?: number;
|
||||
from?: number;
|
||||
to?: number;
|
||||
};
|
||||
|
||||
export const selectedTextAsLatex = (editor: Editor) =>
|
||||
editor.state.doc.textBetween(editor.state.selection.from, editor.state.selection.to, " ").trim();
|
||||
|
||||
export const resolveMathFormulaTarget = (editor: Editor, pos?: number): MathFormulaTarget | null => {
|
||||
const documentPos = pos ?? editor.state.selection.from;
|
||||
const node = editor.state.doc.nodeAt(documentPos);
|
||||
if (!node) return null;
|
||||
if (node.type.name === INLINE_MATH_NODE_TYPE) {
|
||||
return { kind: "inline", pos: documentPos, latex: String(node.attrs.latex ?? "") };
|
||||
}
|
||||
if (node.type.name === BLOCK_MATH_NODE_TYPE) {
|
||||
return { kind: "block", pos: documentPos, latex: String(node.attrs.latex ?? "") };
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export const applyMathFormula = (editor: Editor, draft: MathFormulaDraft) => {
|
||||
const latex = draft.latex.trim();
|
||||
if (!latex) return false;
|
||||
|
||||
if (typeof draft.pos === "number") {
|
||||
return draft.kind === "inline"
|
||||
? editor.commands.updateInlineMath({ latex, pos: draft.pos })
|
||||
: editor.commands.updateBlockMath({ latex, pos: draft.pos });
|
||||
}
|
||||
|
||||
const from = draft.from ?? editor.state.selection.from;
|
||||
const to = draft.to ?? editor.state.selection.to;
|
||||
const chain = editor.chain();
|
||||
if (to > from) {
|
||||
chain.deleteRange({ from, to });
|
||||
}
|
||||
return draft.kind === "inline"
|
||||
? chain.insertInlineMath({ latex, pos: from }).run()
|
||||
: chain.insertBlockMath({ latex, pos: from }).run();
|
||||
};
|
||||
|
||||
export const deleteMathFormula = (editor: Editor, target: Pick<MathFormulaTarget, "kind" | "pos">) => {
|
||||
return target.kind === "inline"
|
||||
? editor.commands.deleteInlineMath({ pos: target.pos })
|
||||
: editor.commands.deleteBlockMath({ pos: target.pos });
|
||||
};
|
||||
@@ -88,7 +88,12 @@ html.dark[data-edgeever-environment="demo"] body::after {
|
||||
}
|
||||
|
||||
.ProseMirror .tiptap-mathematics-render--editable {
|
||||
cursor: text;
|
||||
cursor: pointer;
|
||||
border-radius: 0.2rem;
|
||||
}
|
||||
|
||||
.ProseMirror .tiptap-mathematics-render--editable:hover {
|
||||
background: rgb(22 160 110 / 0.08);
|
||||
}
|
||||
|
||||
.ProseMirror .inline-math-error,
|
||||
@@ -697,6 +702,54 @@ textarea {
|
||||
font-feature-settings: "chws" 1;
|
||||
}
|
||||
|
||||
.edgeever-block-drag-handle {
|
||||
z-index: 20;
|
||||
display: none;
|
||||
width: 1.25rem;
|
||||
height: 1.5rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: grab;
|
||||
border-radius: 0.25rem;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.edgeever-block-drag-handle:hover {
|
||||
background: #f1f5f9;
|
||||
color: #16a06e;
|
||||
}
|
||||
|
||||
.edgeever-block-drag-handle:active,
|
||||
.edgeever-block-drag-handle[data-dragging="true"] {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.edgeever-block-drag-handle {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
.ProseMirror-selectednoderange {
|
||||
border-radius: 0.25rem;
|
||||
background: rgb(22 160 110 / 0.06);
|
||||
outline: 2px solid rgb(22 160 110 / 0.28);
|
||||
}
|
||||
|
||||
:root.dark .edgeever-block-drag-handle {
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
:root.dark .edgeever-block-drag-handle:hover {
|
||||
background: #1e293b;
|
||||
color: #34d399;
|
||||
}
|
||||
|
||||
:root.dark .ProseMirror-selectednoderange {
|
||||
background: rgb(52 211 153 / 0.1);
|
||||
outline-color: rgb(52 211 153 / 0.35);
|
||||
}
|
||||
|
||||
.ProseMirror [data-type="details"] {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
|
||||
@@ -25,15 +25,20 @@
|
||||
"@tanstack/react-query": "latest",
|
||||
"@tanstack/react-virtual": "^3.14.13",
|
||||
"@tiptap/core": "3.31.0",
|
||||
"@tiptap/extension-collaboration": "3.31.0",
|
||||
"@tiptap/extension-details": "3.31.0",
|
||||
"@tiptap/extension-drag-handle": "3.31.0",
|
||||
"@tiptap/extension-drag-handle-react": "3.31.0",
|
||||
"@tiptap/extension-image": "3.31.0",
|
||||
"@tiptap/extension-list": "3.31.0",
|
||||
"@tiptap/extension-node-range": "3.31.0",
|
||||
"@tiptap/extension-placeholder": "3.31.0",
|
||||
"@tiptap/extension-table": "3.31.0",
|
||||
"@tiptap/pm": "3.31.0",
|
||||
"@tiptap/react": "3.31.0",
|
||||
"@tiptap/starter-kit": "3.31.0",
|
||||
"@tiptap/suggestion": "3.31.0",
|
||||
"@tiptap/y-tiptap": "3.0.7",
|
||||
"ai": "^7.0.58",
|
||||
"aws4fetch": "^1.0.20",
|
||||
"class-variance-authority": "latest",
|
||||
@@ -97,7 +102,7 @@
|
||||
},
|
||||
"apps/desktop": {
|
||||
"name": "@edgeever/desktop",
|
||||
"version": "1.78.1",
|
||||
"version": "1.79.0",
|
||||
"dependencies": {
|
||||
"@edgeever/public-network": "workspace:*",
|
||||
"croner": "^10.0.1",
|
||||
@@ -1377,10 +1382,16 @@
|
||||
|
||||
"@tiptap/extension-code-block-lowlight": ["@tiptap/extension-code-block-lowlight@3.31.0", "", { "peerDependencies": { "@tiptap/core": "3.31.0", "@tiptap/extension-code-block": "3.31.0", "@tiptap/pm": "3.31.0", "highlight.js": "^11", "lowlight": "^2 || ^3" } }, "sha512-fFwXqwh0CwJkQzdqgv4ObcS70m0Muq1RbAZUTQNZFqCDcu1krDJW0PWNmua7+nZdmXwOyA2PDUTAtWYFQ+srpw=="],
|
||||
|
||||
"@tiptap/extension-collaboration": ["@tiptap/extension-collaboration@3.31.0", "", { "peerDependencies": { "@tiptap/core": "3.31.0", "@tiptap/pm": "3.31.0", "@tiptap/y-tiptap": "^3.0.7", "yjs": "^13" } }, "sha512-VkfOhXOVK6YJx2vHrDTuaoSt9kQK1QVjpnbe2svU57fDtY3z7pUxdulzgk9ntQ7/6mjyhODugmOm0UuQKYwBxA=="],
|
||||
|
||||
"@tiptap/extension-details": ["@tiptap/extension-details@3.31.0", "", { "peerDependencies": { "@tiptap/core": "3.31.0", "@tiptap/extension-text-style": "3.31.0", "@tiptap/pm": "3.31.0" } }, "sha512-j1ZDrXRPPvCoOrKrVV1e2BXFpHVsHYmikr2ORtI0+gDh/+4qruqrm7ftGic2lm77B2hzKtm2adoSSi9XL6UQew=="],
|
||||
|
||||
"@tiptap/extension-document": ["@tiptap/extension-document@3.31.0", "", { "peerDependencies": { "@tiptap/core": "3.31.0" } }, "sha512-C2eDFoBlcRAb4sgbc9ZH+mrFFbjIS9VyoERGeDum3QRdqLEKU1h14Th7isHpGQzTvTywhXQxqXC64mgeSGXLsA=="],
|
||||
|
||||
"@tiptap/extension-drag-handle": ["@tiptap/extension-drag-handle@3.31.0", "", { "dependencies": { "@floating-ui/dom": "^1.6.13" }, "peerDependencies": { "@tiptap/core": "3.31.0", "@tiptap/extension-collaboration": "3.31.0", "@tiptap/extension-node-range": "3.31.0", "@tiptap/pm": "3.31.0", "@tiptap/y-tiptap": "^3.0.7" } }, "sha512-v04z6nHLGaxC0EYJ6hhWzsl01LfuiMLseTXYEqls5nnl0nmYntPBiwHX2ZoHJ5e42qi76nVO2G06fyigUsI10w=="],
|
||||
|
||||
"@tiptap/extension-drag-handle-react": ["@tiptap/extension-drag-handle-react@3.31.0", "", { "peerDependencies": { "@tiptap/extension-drag-handle": "3.31.0", "@tiptap/pm": "3.31.0", "@tiptap/react": "3.31.0", "react": "^16.8 || ^17 || ^18 || ^19", "react-dom": "^16.8 || ^17 || ^18 || ^19" } }, "sha512-sDAUF5HS0aytRjhbX+jnHzkD695W9Ha4B2SkOcpM1wDSmaYbTdgkwaFh2BZPkd+Hb/J2kdBgjR+ceZE9FU6R2Q=="],
|
||||
|
||||
"@tiptap/extension-dropcursor": ["@tiptap/extension-dropcursor@3.31.0", "", { "peerDependencies": { "@tiptap/extensions": "3.31.0" } }, "sha512-hIZOPfM0sGkAhPV69IK1zWMbWBd3wmhS0XCKBRcXUWrE2MW4kkaSyYprSFqtqFuUa3lVIEFrlME1WOEBJ2/2rg=="],
|
||||
|
||||
"@tiptap/extension-floating-menu": ["@tiptap/extension-floating-menu@3.31.0", "", { "peerDependencies": { "@floating-ui/dom": "^1.0.0", "@tiptap/core": "3.31.0", "@tiptap/pm": "3.31.0" } }, "sha512-NFwv1Kah3zGLmjj53Z+pA2584GdgMDClgvebJPqSunsSdHwHlFwfu+Op1u73xuEYOd6wGgCgzzDZ3V4CKwkJqg=="],
|
||||
@@ -1407,6 +1418,8 @@
|
||||
|
||||
"@tiptap/extension-mathematics": ["@tiptap/extension-mathematics@3.31.0", "", { "peerDependencies": { "@tiptap/core": "3.31.0", "@tiptap/pm": "3.31.0", "katex": "^0.16.4 || ^0.17.0 || ^0.18.0" } }, "sha512-wr3L8zb/jGWPeYb7xvn5AUqUz/ORR6V/kFRGqmZwdM45ksU2txt06Jj16eu5uiUDdGIcLgx0QiMw6hgUyJZAQg=="],
|
||||
|
||||
"@tiptap/extension-node-range": ["@tiptap/extension-node-range@3.31.0", "", { "peerDependencies": { "@tiptap/core": "3.31.0", "@tiptap/pm": "3.31.0" } }, "sha512-QiBWWVdnmQHMKRKrN4UySUaqqTK2jjqZL6Jtz6xFzrrZYI/iS91Uf0J6g4NaM6dfm+8ODUBL8m7aHeYbGZam6A=="],
|
||||
|
||||
"@tiptap/extension-ordered-list": ["@tiptap/extension-ordered-list@3.31.0", "", { "peerDependencies": { "@tiptap/extension-list": "3.31.0" } }, "sha512-KLCax7pI/3JTCkMY01n2+CXwZRPPsIVR1fvSmkFMryUPonkkMYWSUlXCuYKvF22+kM0gU0kiXsVjqsn/YFdeDg=="],
|
||||
|
||||
"@tiptap/extension-paragraph": ["@tiptap/extension-paragraph@3.31.0", "", { "peerDependencies": { "@tiptap/core": "3.31.0" } }, "sha512-78l9D/jpKvvWXrBBzYPfgqwonBY76+1MMgSovkPffIbya+jaYDJdZhIpeCZXtSBDnw8cYE/rheAeusN1QG3SVQ=="],
|
||||
@@ -1435,6 +1448,8 @@
|
||||
|
||||
"@tiptap/suggestion": ["@tiptap/suggestion@3.31.0", "", { "peerDependencies": { "@floating-ui/dom": "^1.0.0", "@tiptap/core": "3.31.0", "@tiptap/pm": "3.31.0" } }, "sha512-p/m8K4jmF+b5JdRcZ5onOKxJf3dsTcXhnV6qAjDu1xrvpY8lXZe4DXgtuAUvh5Q4Msmq6IYuMAvleR4WAkIXIQ=="],
|
||||
|
||||
"@tiptap/y-tiptap": ["@tiptap/y-tiptap@3.0.7", "", { "dependencies": { "lib0": "^0.2.100" }, "peerDependencies": { "prosemirror-model": "^1.7.1", "prosemirror-state": "^1.2.3", "prosemirror-view": "^1.9.10", "y-protocols": "^1.0.1", "yjs": "^13.5.38" } }, "sha512-3VG01F7i2JDghWsBZSBKi7ypMiN5UVVSyD18IwoXx7ilm0ZynrTS+XNpmgxfuiSBjdauWk7tUlP04xfM8Bw4Vw=="],
|
||||
|
||||
"@trickfilm400/rollup-plugin-off-main-thread": ["@trickfilm400/rollup-plugin-off-main-thread@3.0.0-pre1", "", { "dependencies": { "ejs": "^3.1.10", "json5": "^2.2.3", "magic-string": "^0.30.21", "string.prototype.matchall": "^4.0.12" } }, "sha512-/67zpWDBLV+oYAEL682s1ktXL0HgqX76f6gaVGkGnVZlBbm1zd0v4Bz8MFF2GGhoX9rvfq3KSQHubFHwa6w6/Q=="],
|
||||
|
||||
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="],
|
||||
@@ -2791,6 +2806,8 @@
|
||||
|
||||
"isexe": ["isexe@3.1.5", "", {}, "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w=="],
|
||||
|
||||
"isomorphic.js": ["isomorphic.js@0.2.5", "", {}, "sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw=="],
|
||||
|
||||
"jackspeak": ["jackspeak@4.2.3", "", { "dependencies": { "@isaacs/cliui": "^9.0.0" } }, "sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg=="],
|
||||
|
||||
"jake": ["jake@10.9.4", "", { "dependencies": { "async": "^3.2.6", "filelist": "^1.0.4", "picocolors": "^1.1.1" }, "bin": { "jake": "bin/cli.js" } }, "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA=="],
|
||||
@@ -2875,6 +2892,8 @@
|
||||
|
||||
"levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="],
|
||||
|
||||
"lib0": ["lib0@0.2.117", "", { "dependencies": { "isomorphic.js": "^0.2.4" }, "bin": { "0serve": "bin/0serve.js", "0gentesthtml": "bin/gentesthtml.js", "0ecdsa-generate-keypair": "bin/0ecdsa-generate-keypair.js" } }, "sha512-DeXj9X5xDCjgKLU/7RR+/HQEVzuuEUiwldwOGsHK/sfAfELGWEyTcf0x+uOvCvK3O2zPmZePXWL85vtia6GyZw=="],
|
||||
|
||||
"lie": ["lie@3.3.0", "", { "dependencies": { "immediate": "~3.0.5" } }, "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ=="],
|
||||
|
||||
"lighthouse-logger": ["lighthouse-logger@2.0.2", "", { "dependencies": { "debug": "^4.4.1", "marky": "^1.2.2" } }, "sha512-vWl2+u5jgOQuZR55Z1WM0XDdrJT6mzMP8zHUct7xTlWhuQs+eV0g+QL0RQdFjT54zVmbhLCP8vIVpy1wGn/gCg=="],
|
||||
@@ -4169,6 +4188,8 @@
|
||||
|
||||
"xxhash-wasm": ["xxhash-wasm@1.1.0", "", {}, "sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA=="],
|
||||
|
||||
"y-protocols": ["y-protocols@1.0.7", "", { "dependencies": { "lib0": "^0.2.85" }, "peerDependencies": { "yjs": "^13.0.0" } }, "sha512-YSVsLoXxO67J6eE/nV4AtFtT3QEotZf5sK5BHxFBXso7VDUT3Tx07IfA6hsu5Q5OmBdMkQVmFZ9QOA7fikWvnw=="],
|
||||
|
||||
"y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
|
||||
|
||||
"yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="],
|
||||
@@ -4185,6 +4206,8 @@
|
||||
|
||||
"yet-another-react-lightbox": ["yet-another-react-lightbox@3.32.0", "", { "peerDependencies": { "@types/react": "^16 || ^17 || ^18 || ^19", "@types/react-dom": "^16 || ^17 || ^18 || ^19", "react": "^16.8.0 || ^17 || ^18 || ^19", "react-dom": "^16.8.0 || ^17 || ^18 || ^19" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-FWODOMrE07i3O5MeWRcYlcnAUk518zkUYKAe307pVW5pkey3hKMcAIWn8yMIURzGvbd3m9eghWO9CocEZmQPIg=="],
|
||||
|
||||
"yjs": ["yjs@13.6.32", "", { "dependencies": { "lib0": "^0.2.99" } }, "sha512-lfiJIIC4Xayt5ItynE407ehlE03pCjeOc4hkR4yxxvvNJ4kuiN25B0g+Qp8XagYz361LLL7DCzR5bvFJ81QKtQ=="],
|
||||
|
||||
"yocto-queue": ["yocto-queue@1.2.2", "", {}, "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ=="],
|
||||
|
||||
"yocto-spinner": ["yocto-spinner@0.2.3", "", { "dependencies": { "yoctocolors": "^2.1.1" } }, "sha512-sqBChb33loEnkoXte1bLg45bEBsOP9N1kzQh5JZNKj/0rik4zAPTNSAVPj3uQAdc6slYJ0Ksc403G2XgxsJQFQ=="],
|
||||
|
||||
@@ -137,15 +137,20 @@
|
||||
"@tanstack/react-query": "latest",
|
||||
"@tanstack/react-virtual": "^3.14.13",
|
||||
"@tiptap/core": "3.31.0",
|
||||
"@tiptap/extension-collaboration": "3.31.0",
|
||||
"@tiptap/extension-details": "3.31.0",
|
||||
"@tiptap/extension-drag-handle": "3.31.0",
|
||||
"@tiptap/extension-drag-handle-react": "3.31.0",
|
||||
"@tiptap/extension-image": "3.31.0",
|
||||
"@tiptap/extension-list": "3.31.0",
|
||||
"@tiptap/extension-node-range": "3.31.0",
|
||||
"@tiptap/extension-placeholder": "3.31.0",
|
||||
"@tiptap/extension-table": "3.31.0",
|
||||
"@tiptap/pm": "3.31.0",
|
||||
"@tiptap/react": "3.31.0",
|
||||
"@tiptap/starter-kit": "3.31.0",
|
||||
"@tiptap/suggestion": "3.31.0",
|
||||
"@tiptap/y-tiptap": "3.0.7",
|
||||
"ai": "^7.0.58",
|
||||
"aws4fetch": "^1.0.20",
|
||||
"class-variance-authority": "latest",
|
||||
|
||||
@@ -1657,6 +1657,7 @@ export const enUS = {
|
||||
},
|
||||
placeholder: "space for AI · / commands · @ notes",
|
||||
placeholderCommands: "/ commands · @ notes",
|
||||
dragHandle: "Drag block",
|
||||
noteBodyAria: "Note body",
|
||||
markdownSourceAria: "Markdown source",
|
||||
loading: "Loading",
|
||||
@@ -1956,6 +1957,8 @@ export const enUS = {
|
||||
mermaidZoomOut: "Zoom out diagram",
|
||||
mermaidResetZoom: "Reset diagram zoom",
|
||||
mermaidCloseViewer: "Close diagram viewer",
|
||||
math: "Insert formula",
|
||||
blockMath: "Display formula",
|
||||
horizontalRule: "Divider",
|
||||
themeBlock: "Insert content block",
|
||||
deleteThemeBlock: "Delete themed block",
|
||||
@@ -2000,6 +2003,21 @@ export const enUS = {
|
||||
errorInvalid: "Enter a valid URL, such as example.com or https://example.com.",
|
||||
errorUnsupported: "This URL scheme is not allowed.",
|
||||
},
|
||||
mathFormulaDialog: {
|
||||
title: "Insert formula",
|
||||
editTitle: "Edit formula",
|
||||
description: "Write the formula in LaTeX. Inline math sits in a sentence; a display formula gets its own line.",
|
||||
inline: "Inline",
|
||||
block: "Display",
|
||||
latexLabel: "LaTeX",
|
||||
latexPlaceholder: "E = mc^2",
|
||||
preview: "Preview",
|
||||
previewEmpty: "Preview appears after you enter LaTeX",
|
||||
previewError: "Could not render this formula",
|
||||
apply: "Apply",
|
||||
remove: "Delete formula",
|
||||
errorEmpty: "Enter LaTeX.",
|
||||
},
|
||||
noteLinkPicker: {
|
||||
title: "Link a note",
|
||||
searchPlaceholder: "Search notes to link",
|
||||
|
||||
@@ -1657,6 +1657,7 @@ export const ja = {
|
||||
},
|
||||
placeholder: "スペースで AI · / コマンド · @ ノート",
|
||||
placeholderCommands: "/ コマンド · @ ノート",
|
||||
dragHandle: "ブロックをドラッグ",
|
||||
noteBodyAria: "ノート本文",
|
||||
markdownSourceAria: "Markdown ソース",
|
||||
loading: "読み込み中",
|
||||
@@ -1956,6 +1957,8 @@ export const ja = {
|
||||
mermaidZoomOut: "図を縮小",
|
||||
mermaidResetZoom: "図のズームをリセット",
|
||||
mermaidCloseViewer: "図ビューアを閉じる",
|
||||
math: "数式を挿入",
|
||||
blockMath: "ブロック数式",
|
||||
horizontalRule: "区切り線",
|
||||
themeBlock: "コンテンツブロックを挿入",
|
||||
deleteThemeBlock: "テーマ付きブロックを削除",
|
||||
@@ -2000,6 +2003,21 @@ export const ja = {
|
||||
errorInvalid: "example.com または https://example.com のような有効な URL を入力してください。",
|
||||
errorUnsupported: "この URL スキームは使えません。",
|
||||
},
|
||||
mathFormulaDialog: {
|
||||
title: "数式を挿入",
|
||||
editTitle: "数式を編集",
|
||||
description: "LaTeX で数式を書きます。インラインは文中に入り、ブロックは独立した行になります。",
|
||||
inline: "インライン",
|
||||
block: "ブロック",
|
||||
latexLabel: "LaTeX",
|
||||
latexPlaceholder: "E = mc^2",
|
||||
preview: "プレビュー",
|
||||
previewEmpty: "LaTeX を入力するとプレビューされます",
|
||||
previewError: "この数式を表示できません",
|
||||
apply: "適用",
|
||||
remove: "数式を削除",
|
||||
errorEmpty: "LaTeX を入力してください。",
|
||||
},
|
||||
noteLinkPicker: {
|
||||
title: "ノートをリンク",
|
||||
searchPlaceholder: "リンクするノートを検索",
|
||||
|
||||
@@ -1657,6 +1657,7 @@ export const zhCN = {
|
||||
},
|
||||
placeholder: "空格唤起 AI · / 命令 · @ 引用",
|
||||
placeholderCommands: "/ 命令 · @ 引用",
|
||||
dragHandle: "拖动块",
|
||||
noteBodyAria: "笔记正文",
|
||||
markdownSourceAria: "Markdown 源码",
|
||||
loading: "加载中",
|
||||
@@ -1954,6 +1955,8 @@ export const zhCN = {
|
||||
mermaidZoomOut: "缩小图表",
|
||||
mermaidResetZoom: "重置图表缩放",
|
||||
mermaidCloseViewer: "关闭图表查看器",
|
||||
math: "插入公式",
|
||||
blockMath: "块级公式",
|
||||
horizontalRule: "分割线",
|
||||
themeBlock: "插入内容块",
|
||||
deleteThemeBlock: "删除主题化组件",
|
||||
@@ -1998,6 +2001,21 @@ export const zhCN = {
|
||||
errorInvalid: "请输入有效网址,例如 example.com 或 https://example.com。",
|
||||
errorUnsupported: "不支持此网址协议。",
|
||||
},
|
||||
mathFormulaDialog: {
|
||||
title: "插入公式",
|
||||
editTitle: "编辑公式",
|
||||
description: "用 LaTeX 编写公式。行内公式会出现在句子中,块级公式单独占一行。",
|
||||
inline: "行内",
|
||||
block: "块级",
|
||||
latexLabel: "LaTeX",
|
||||
latexPlaceholder: "E = mc^2",
|
||||
preview: "预览",
|
||||
previewEmpty: "输入 LaTeX 后显示预览",
|
||||
previewError: "无法渲染该公式",
|
||||
apply: "应用",
|
||||
remove: "删除公式",
|
||||
errorEmpty: "请输入 LaTeX。",
|
||||
},
|
||||
noteLinkPicker: {
|
||||
title: "引用笔记",
|
||||
searchPlaceholder: "搜索要引用的笔记",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { BlockMath, InlineMath } from "@tiptap/extension-mathematics";
|
||||
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
|
||||
import {
|
||||
BLOCK_MATH_NODE_TYPE,
|
||||
edgeEverBlockMathMarkdownTokenizer,
|
||||
@@ -11,6 +12,13 @@ export {
|
||||
INLINE_MATH_NODE_TYPE,
|
||||
} from "./mathematics-markdown";
|
||||
|
||||
export type EdgeEverMathematicsClickHandler = (node: ProseMirrorNode, pos: number) => void;
|
||||
|
||||
export type CreateEdgeEverMathematicsOptions = {
|
||||
onInlineClick?: EdgeEverMathematicsClickHandler;
|
||||
onBlockClick?: EdgeEverMathematicsClickHandler;
|
||||
};
|
||||
|
||||
const EdgeEverInlineMath = InlineMath.extend({
|
||||
markdownTokenizer: edgeEverInlineMathMarkdownTokenizer,
|
||||
});
|
||||
@@ -26,7 +34,7 @@ const katexOptions = {
|
||||
};
|
||||
|
||||
/** Fresh extension instances for each TipTap editor or Markdown manager. */
|
||||
export const createEdgeEverMathematics = () => [
|
||||
EdgeEverBlockMath.configure({ katexOptions }),
|
||||
EdgeEverInlineMath.configure({ katexOptions }),
|
||||
export const createEdgeEverMathematics = (options: CreateEdgeEverMathematicsOptions = {}) => [
|
||||
EdgeEverBlockMath.configure({ katexOptions, onClick: options.onBlockClick }),
|
||||
EdgeEverInlineMath.configure({ katexOptions, onClick: options.onInlineClick }),
|
||||
];
|
||||
Reference in new issue
Block a user