From 2779be6494f878c7de66642eed11970c60486159 Mon Sep 17 00:00:00 2001 From: Rotimi Best Date: Thu, 25 Dec 2025 14:22:39 +0100 Subject: [PATCH] feat: update edra to 2.3.0 (#618) * feat: Add mathematics extension and toolbar commands This commit introduces the mathematics extension for inline and block expressions. It also adds corresponding toolbar commands and updates the drag handle menu to include "Turn Into" and "Add Node" options. The placeholder styling and blockquote styles have also been refined. Co-authored-by: best * Update Tiptap and KaTeX dependencies Co-authored-by: best * Downgrade svelte-tiptap to v2.2.0 Co-authored-by: best --------- Co-authored-by: Cursor Agent --- packages/ui/package.json | 42 +++---- .../editor/commands/toolbar-commands.ts | 103 +++++++++++++++++- .../ui/src/custom/editor/commands/types.ts | 1 + packages/ui/src/custom/editor/editor.css | 51 ++++++++- packages/ui/src/custom/editor/editor.ts | 47 +++++--- .../editor/extensions/InlineMathReplacer.ts | 21 ++++ .../editor/extensions/slash-command/groups.ts | 1 + .../editor/ui/drag-handle-extended.svelte | 71 +++++++++++- .../ui/src/custom/editor/ui/menus/Menu.svelte | 6 +- 9 files changed, 299 insertions(+), 44 deletions(-) create mode 100644 packages/ui/src/custom/editor/extensions/InlineMathReplacer.ts diff --git a/packages/ui/package.json b/packages/ui/package.json index b20b31ea..bbf6d061 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -37,33 +37,33 @@ "vaul-svelte": "1.0.0-next.7" }, "dependencies": { - "@aarkue/tiptap-math-extension": "^1.4.0", "@floating-ui/dom": "^1.7.4", - "@tiptap/core": "^3.7.1", - "@tiptap/extension-bubble-menu": "^3.7.1", - "@tiptap/extension-code-block-lowlight": "^3.7.1", - "@tiptap/extension-highlight": "^3.7.1", - "@tiptap/extension-image": "^3.7.1", - "@tiptap/extension-list": "^3.7.1", - "@tiptap/extension-subscript": "^3.7.1", - "@tiptap/extension-superscript": "^3.7.1", - "@tiptap/extension-table": "^3.7.1", - "@tiptap/extension-text-align": "^3.7.1", - "@tiptap/extension-text-style": "^3.7.1", - "@tiptap/extension-typography": "^3.7.1", - "@tiptap/extensions": "^3.7.1", - "@tiptap/pm": "^3.7.1", - "@tiptap/starter-kit": "^3.7.1", - "@tiptap/suggestion": "^3.7.1", + "@tiptap/core": "^3.10.7", + "@tiptap/extension-bubble-menu": "^3.10.7", + "@tiptap/extension-code-block-lowlight": "^3.10.7", + "@tiptap/extension-highlight": "^3.10.7", + "@tiptap/extension-image": "^3.10.7", + "@tiptap/extension-list": "^3.10.7", + "@tiptap/extension-mathematics": "^3.10.7", + "@tiptap/extension-subscript": "^3.10.7", + "@tiptap/extension-superscript": "^3.10.7", + "@tiptap/extension-table": "^3.10.7", + "@tiptap/extension-text-align": "^3.10.7", + "@tiptap/extension-text-style": "^3.10.7", + "@tiptap/extension-typography": "^3.10.7", + "@tiptap/extensions": "^3.10.7", + "@tiptap/markdown": "^3.10.7", + "@tiptap/pm": "^3.10.7", + "@tiptap/starter-kit": "^3.10.7", + "@tiptap/suggestion": "^3.10.7", "d3-array": "^3.2.4", "d3-scale": "^4.0.2", "dompurify": "^3.3.0", - "katex": "^0.16.22", + "katex": "^0.16.25", "lowlight": "^3.3.0", - "svelte-tiptap": "^3.0.0", + "svelte-tiptap": "^2.2.0", "tippy.js": "^6.3.7", - "tiptap-extension-auto-joiner": "^0.1.3", - "tiptap-markdown": "^0.9.0" + "tiptap-extension-auto-joiner": "^0.1.3" }, "peerDependencies": { "bits-ui": "^2.14.4" diff --git a/packages/ui/src/custom/editor/commands/toolbar-commands.ts b/packages/ui/src/custom/editor/commands/toolbar-commands.ts index 3f3d9192..8d3191f8 100644 --- a/packages/ui/src/custom/editor/commands/toolbar-commands.ts +++ b/packages/ui/src/custom/editor/commands/toolbar-commands.ts @@ -26,6 +26,10 @@ import Table from '@lucide/svelte/icons/table'; import Underline from '@lucide/svelte/icons/underline'; import Undo from '@lucide/svelte/icons/undo-2'; import Video from '@lucide/svelte/icons/video'; +import Radical from '@lucide/svelte/icons/radical'; +import SquareRadical from '@lucide/svelte/icons/square-radical'; +import { isTextSelection } from '@tiptap/core'; +import Pilcrow from '@lucide/svelte/icons/pilcrow'; import { isMac } from '../utils'; const commands: Record = { @@ -64,6 +68,9 @@ const commands: Record = { onClick: (editor) => { editor.chain().focus().toggleHeading({ level: 1 }).run(); }, + turnInto: (editor, pos) => { + editor.chain().setNodeSelection(pos).setHeading({ level: 1 }).run(); + }, clickable: (editor) => { return editor.can().toggleHeading({ level: 1 }); }, @@ -79,6 +86,9 @@ const commands: Record = { onClick: (editor) => { editor.chain().focus().toggleHeading({ level: 2 }).run(); }, + turnInto: (editor, pos) => { + editor.chain().setNodeSelection(pos).setHeading({ level: 2 }).run(); + }, clickable: (editor) => { return editor.can().toggleHeading({ level: 2 }); }, @@ -94,6 +104,9 @@ const commands: Record = { onClick: (editor) => { editor.chain().focus().toggleHeading({ level: 3 }).run(); }, + turnInto: (editor, pos) => { + editor.chain().setNodeSelection(pos).setHeading({ level: 3 }).run(); + }, clickable: (editor) => { return editor.can().toggleHeading({ level: 3 }); }, @@ -109,6 +122,9 @@ const commands: Record = { onClick: (editor) => { editor.chain().focus().toggleHeading({ level: 4 }).run(); }, + turnInto: (editor, pos) => { + editor.chain().setNodeSelection(pos).setHeading({ level: 4 }).run(); + }, clickable: (editor) => { return editor.can().toggleHeading({ level: 4 }); }, @@ -136,6 +152,24 @@ const commands: Record = { return editor.isActive('link'); } }, + { + icon: Pilcrow, + name: 'paragraph', + tooltip: 'Paragraph', + shortCut: `${isMac ? '⌘⇧' : 'Ctrl+Shift+'}0`, + onClick: (editor) => { + editor.chain().focus().setParagraph().run(); + }, + turnInto: (editor, pos) => { + editor.chain().setNodeSelection(pos).setParagraph().run(); + }, + clickable: (editor) => { + return editor.can().setParagraph(); + }, + isActive: (editor) => { + return editor.isActive('paragraph'); + } + }, { icon: Bold, name: 'bold', @@ -144,6 +178,9 @@ const commands: Record = { onClick: (editor) => { editor.chain().focus().toggleBold().run(); }, + turnInto: (editor, pos) => { + editor.chain().setNodeSelection(pos).setMark('bold').run(); + }, clickable: (editor) => { return editor.can().toggleBold(); }, @@ -159,6 +196,9 @@ const commands: Record = { onClick: (editor) => { editor.chain().focus().toggleItalic().run(); }, + turnInto: (editor, pos) => { + editor.chain().setNodeSelection(pos).setMark('italic').run(); + }, clickable: (editor) => { return editor.can().toggleItalic(); }, @@ -174,6 +214,9 @@ const commands: Record = { onClick: (editor) => { editor.chain().focus().toggleUnderline().run(); }, + turnInto: (editor, pos) => { + editor.chain().setNodeSelection(pos).setMark('underline').run(); + }, clickable: (editor) => { return editor.can().toggleUnderline(); }, @@ -189,6 +232,9 @@ const commands: Record = { onClick: (editor) => { editor.chain().focus().toggleStrike().run(); }, + turnInto: (editor, pos) => { + editor.chain().setNodeSelection(pos).setMark('strike').run(); + }, clickable: (editor) => { return editor.can().toggleStrike(); }, @@ -204,6 +250,9 @@ const commands: Record = { onClick: (editor) => { editor.chain().focus().toggleBlockquote().run(); }, + turnInto: (editor, pos) => { + editor.chain().setNodeSelection(pos).toggleBlockquote().run(); + }, clickable: (editor) => { return editor.can().toggleBlockquote(); }, @@ -219,6 +268,9 @@ const commands: Record = { onClick: (editor) => { editor.chain().focus().toggleCode().run(); }, + turnInto: (editor, pos) => { + editor.chain().setNodeSelection(pos).toggleCodeBlock().run(); + }, clickable: (editor) => { return editor.can().toggleCode(); }, @@ -320,6 +372,9 @@ const commands: Record = { onClick: (editor) => { editor.chain().focus().toggleBulletList().run(); }, + turnInto: (editor, pos) => { + editor.chain().setNodeSelection(pos).toggleBulletList().run(); + }, isActive: (editor) => editor.isActive('bulletList') }, { @@ -330,7 +385,15 @@ const commands: Record = { onClick: (editor) => { editor.chain().focus().toggleOrderedList().run(); }, - isActive: (editor) => editor.isActive('orderedList') + turnInto: (editor, pos) => { + editor.chain().setNodeSelection(pos).toggleOrderedList().run(); + }, + clickable: (editor) => { + return editor.can().toggleOrderedList(); + }, + isActive: (editor) => { + return editor.isActive('orderedList'); + } }, { icon: ListChecks, @@ -340,7 +403,15 @@ const commands: Record = { onClick: (editor) => { editor.chain().focus().toggleTaskList().run(); }, - isActive: (editor) => editor.isActive('taskList') + turnInto: (editor, pos) => { + editor.chain().setNodeSelection(pos).toggleTaskList().run(); + }, + clickable: (editor) => { + return editor.can().toggleTaskList(); + }, + isActive: (editor) => { + return editor.isActive('taskList'); + } } ], media: [ @@ -398,6 +469,34 @@ const commands: Record = { }, isActive: (editor) => editor.isActive('table') } + ], + math: [ + { + icon: Radical, + name: 'mathematics', + tooltip: 'Inline Expression', + onClick: (editor) => { + let latex = 'a^2 + b^2 = c^2'; + const chain = editor.chain().focus(); + if (isTextSelection(editor.view.state.selection)) { + const { from, to } = editor.view.state.selection; + latex = editor.view.state.doc.textBetween(from, to); + chain.deleteRange({ from, to }); + } + chain.insertInlineMath({ latex }).run(); + }, + isActive: (editor) => editor.isActive('inlineMath') + }, + { + icon: SquareRadical, + name: 'mathematics', + tooltip: 'Block Expression', + onClick: (editor) => { + const latex = 'a^2 + b^2 = c^2'; + editor.chain().focus().insertBlockMath({ latex }).run(); + }, + isActive: (editor) => editor.isActive('blockMath') + } ] }; diff --git a/packages/ui/src/custom/editor/commands/types.ts b/packages/ui/src/custom/editor/commands/types.ts index b677b7da..d22decd2 100644 --- a/packages/ui/src/custom/editor/commands/types.ts +++ b/packages/ui/src/custom/editor/commands/types.ts @@ -7,6 +7,7 @@ export interface EdraToolBarCommands { tooltip?: string; shortCut?: string; onClick?: (editor: Editor) => void; + turnInto?: (editor: Editor, pos: number) => void; isActive?: (editor: Editor) => boolean; clickable?: (editor: Editor) => boolean; } diff --git a/packages/ui/src/custom/editor/editor.css b/packages/ui/src/custom/editor/editor.css index 8904efae..e0ede8f6 100644 --- a/packages/ui/src/custom/editor/editor.css +++ b/packages/ui/src/custom/editor/editor.css @@ -5,11 +5,12 @@ } /* For Placeholder */ -.tiptap .is-empty::before { + +.tiptap .is-empty:not(blockquote.is-empty)::before { pointer-events: none; float: left; height: 0; - color: var(--border-color-hover); + color: var(--color-muted-foreground); content: attr(data-placeholder); } @@ -66,12 +67,12 @@ } .tiptap blockquote::before { - content: ''; + content: ' '; position: absolute; left: 0; top: 0; bottom: 0; - width: 0.25rem; + width: 0.4rem; background-color: var(--blockquote-border); border-radius: 0.5rem; } @@ -208,8 +209,8 @@ input[type='checkbox'] { align-items: center; cursor: grab; opacity: 100; - transition-property: opacity; - transition-duration: 200ms; + transition-property: all; + transition-duration: 300ms; transition-timing-function: cubic-bezier(0.4, 0, 1, 1); color: var(--border-color-hover); } @@ -452,3 +453,41 @@ input[type='checkbox'] { .tiptap iframe { aspect-ratio: 16 / 9; } + +/* Mathematics extension styles */ +.tiptap .tiptap-mathematics-render { + padding: 0 0.25rem; + border-radius: 0.25rem; +} + +/* Editable math block */ +.tiptap .tiptap-mathematics-render--editable { + cursor: pointer; + transition: background 0.2s; +} + +.tiptap .tiptap-mathematics-render--editable:hover { + background: var(--code-bg); +} + +/* Inline math */ +.tiptap .tiptap-mathematics-render[data-type='inline-math'] { + display: inline-block; +} + +/* Block math */ +.tiptap .tiptap-mathematics-render[data-type='block-math'] { + display: block; + margin: 1rem 0; + padding: 0.5rem; + text-align: center; + font-size: 1.25rem; +} + +/* Error styles */ +.tiptap .tiptap-mathematics-render.inline-math-error, +.tiptap .tiptap-mathematics-render.block-math-error { + background: var(--code-bg); + color: var(--color-destructive); + border: 1px solid darkred; +} diff --git a/packages/ui/src/custom/editor/editor.ts b/packages/ui/src/custom/editor/editor.ts index 92ab6827..616c32c5 100644 --- a/packages/ui/src/custom/editor/editor.ts +++ b/packages/ui/src/custom/editor/editor.ts @@ -12,10 +12,12 @@ import SearchAndReplace from './extensions/FindAndReplace'; import { TaskItem, TaskList } from '@tiptap/extension-list'; import { Table, TableCell, TableRow, TableHeader } from './extensions/table'; import { Placeholder } from '@tiptap/extensions'; -import { Markdown } from 'tiptap-markdown'; -import MathExtension from '@aarkue/tiptap-math-extension'; +import { Markdown } from '@tiptap/markdown'; +import MathMatics from '@tiptap/extension-mathematics'; + import AutoJoiner from 'tiptap-extension-auto-joiner'; import 'katex/dist/katex.min.css'; +import { InlineMathReplacer } from './extensions/InlineMathReplacer'; export default ( element?: HTMLElement, @@ -82,22 +84,41 @@ export default ( nested: true }), SearchAndReplace, - MathExtension.configure({ evaluation: true }), + InlineMathReplacer, + MathMatics.configure({ + blockOptions: { + onClick: (node, pos) => { + const newCalculation = prompt('Enter new calculation:', node.attrs.latex); + if (newCalculation) { + editor + .chain() + .setNodeSelection(pos) + .updateBlockMath({ latex: newCalculation }) + .focus() + .run(); + } + } + }, + inlineOptions: { + onClick: (node, pos) => { + const newCalculation = prompt('Enter new calculation:', node.attrs.latex); + if (newCalculation) { + editor + .chain() + .setNodeSelection(pos) + .updateInlineMath({ latex: newCalculation }) + .focus() + .run(); + } + } + } + }), AutoJoiner, Table, TableHeader, TableRow, TableCell, - Markdown.configure({ - html: true, - tightLists: true, - tightListClass: 'tight', - bulletListMarker: '-', - linkify: true, - breaks: true, - transformPastedText: true, - transformCopiedText: false - }), + Markdown, ...(extensions ?? []) ], diff --git a/packages/ui/src/custom/editor/extensions/InlineMathReplacer.ts b/packages/ui/src/custom/editor/extensions/InlineMathReplacer.ts new file mode 100644 index 00000000..f4a08d06 --- /dev/null +++ b/packages/ui/src/custom/editor/extensions/InlineMathReplacer.ts @@ -0,0 +1,21 @@ +import { textInputRule } from '@tiptap/core'; +import { InlineMath } from '@tiptap/extension-mathematics'; + +export const InlineMathReplacer = InlineMath.extend({ + name: 'inlineMathReplacer', + addInputRules() { + return [ + textInputRule({ + find: /\$\$([^$]+)\$\$/, + replace: ({ match, commands }) => { + const latex = match[1]; + // Insert the inline math node with the LaTeX content + commands.insertInlineMath({ + latex + }); + return ''; + } + }) + ]; + } +}); diff --git a/packages/ui/src/custom/editor/extensions/slash-command/groups.ts b/packages/ui/src/custom/editor/extensions/slash-command/groups.ts index da4e7b93..882e5b6e 100644 --- a/packages/ui/src/custom/editor/extensions/slash-command/groups.ts +++ b/packages/ui/src/custom/editor/extensions/slash-command/groups.ts @@ -42,6 +42,7 @@ export const GROUPS: Group[] = [ actions: [ ...commands.media, ...commands.table, + ...commands.math, { icon: Minus, name: 'horizontalRule', diff --git a/packages/ui/src/custom/editor/ui/drag-handle-extended.svelte b/packages/ui/src/custom/editor/ui/drag-handle-extended.svelte index 3afe23e6..92df5e88 100644 --- a/packages/ui/src/custom/editor/ui/drag-handle-extended.svelte +++ b/packages/ui/src/custom/editor/ui/drag-handle-extended.svelte @@ -10,7 +10,10 @@ import Duplicate from '@lucide/svelte/icons/copy'; import Clipboard from '@lucide/svelte/icons/clipboard'; import Delete from '@lucide/svelte/icons/trash-2'; + import Repeat2 from '@lucide/svelte/icons/repeat-2'; + import Plus from '@lucide/svelte/icons/plus'; import { NodeSelection } from '@tiptap/pm/state'; + import commands from '../commands/toolbar-commands'; interface Props { editor: Editor; @@ -21,6 +24,9 @@ let currentNode: Node | null = $state(null); let currentNodePos: number = $state(-1); let open = $state(false); + const turnIntoCommand = Object.values(commands) + .flat() + .filter((c) => c.turnInto !== undefined); const pluginKey = 'globalDragHandle'; @@ -71,10 +77,39 @@ const handleDelete = () => { editor.chain().setMeta('hideDragHandle', true).setNodeSelection(currentNodePos).deleteSelection().run(); }; + + const handleAddNodeNext = () => { + if (currentNodePos === -1) return; + const currentNodeSize = currentNode?.nodeSize || 0; + const insertPos = currentNodePos + currentNodeSize; + const currentNodeIsEmptyParagraph = + currentNode?.type.name === 'paragraph' && currentNode?.content?.size === 0; + const focusPos = currentNodeIsEmptyParagraph ? currentNodePos + 2 : insertPos + 2; + editor + .chain() + .command(({ dispatch, tr, state }) => { + if (dispatch) { + if (currentNodeIsEmptyParagraph) { + tr.insertText('/', currentNodePos, currentNodePos + 1); + } else { + tr.insert( + insertPos, + state.schema.nodes.paragraph.create(null, [state.schema.text('/')]) + ); + } + + return dispatch(tr); + } + + return true; + }) + .focus(focusPos) + .run(); + };
- @@ -82,10 +117,43 @@ Drag Handle + + + {currentNode?.type.name} + + + + + Turn Into + + + {#each turnIntoCommand as command (command)} + {@const Icon = command.icon} + { + if (currentNode && currentNodePos) command.turnInto?.(editor, currentNodePos); + }} + > + + {command.tooltip} + {command.shortCut} + + {/each} + + + + + + + Add Node + Remove Formatting + Duplicate Node @@ -94,6 +162,7 @@ Copy to clipboard + Delete Node diff --git a/packages/ui/src/custom/editor/ui/menus/Menu.svelte b/packages/ui/src/custom/editor/ui/menus/Menu.svelte index bdf36a67..dc69ea16 100644 --- a/packages/ui/src/custom/editor/ui/menus/Menu.svelte +++ b/packages/ui/src/custom/editor/ui/menus/Menu.svelte @@ -106,7 +106,11 @@ {:else} {@const commandGroup = commands[cmd]} {#each commandGroup as command (command)} - + {#if command.name === 'paragraph'} + + {:else} + + {/if} {/each} {/if} {/each}