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 <best@classroomio.com>

* Update Tiptap and KaTeX dependencies

Co-authored-by: best <best@classroomio.com>

* Downgrade svelte-tiptap to v2.2.0

Co-authored-by: best <best@classroomio.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
This commit is contained in:
Rotimi Best
2025-12-25 14:22:39 +01:00
committed by GitHub
co-authored by Cursor Agent
parent 50d6ee3111
commit 2779be6494
9 changed files with 299 additions and 44 deletions
+21 -21
View File
@@ -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"
@@ -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<string, EdraToolBarCommands[]> = {
@@ -64,6 +68,9 @@ const commands: Record<string, EdraToolBarCommands[]> = {
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<string, EdraToolBarCommands[]> = {
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<string, EdraToolBarCommands[]> = {
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<string, EdraToolBarCommands[]> = {
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<string, EdraToolBarCommands[]> = {
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<string, EdraToolBarCommands[]> = {
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<string, EdraToolBarCommands[]> = {
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<string, EdraToolBarCommands[]> = {
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<string, EdraToolBarCommands[]> = {
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<string, EdraToolBarCommands[]> = {
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<string, EdraToolBarCommands[]> = {
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<string, EdraToolBarCommands[]> = {
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<string, EdraToolBarCommands[]> = {
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<string, EdraToolBarCommands[]> = {
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<string, EdraToolBarCommands[]> = {
},
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')
}
]
};
@@ -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;
}
+45 -6
View File
@@ -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;
}
+34 -13
View File
@@ -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 ?? [])
],
@@ -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 '';
}
})
];
}
});
@@ -42,6 +42,7 @@ export const GROUPS: Group[] = [
actions: [
...commands.media,
...commands.table,
...commands.math,
{
icon: Minus,
name: 'horizontalRule',
@@ -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();
};
</script>
<div class="drag-handle">
<Button variant="ghost" class="ui:rounded-sm ui:p-0 !size-6" onclick={() => (open = true)}>
<Button variant="ghost" class="!ui:size-6 ui:rounded-sm ui:p-0" onclick={() => (open = true)}>
<GripVertical />
</Button>
<DropdownMenu.Root bind:open>
@@ -82,10 +117,43 @@
<span>Drag Handle</span>
</DropdownMenu.Trigger>
<DropdownMenu.Content>
<DropdownMenu.Group>
<DropdownMenu.GroupHeading class="ui:text-muted-foreground ui:capitalize">
{currentNode?.type.name}
</DropdownMenu.GroupHeading>
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger openDelay={300}>
<Repeat2 />
Turn Into
</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent class="ui:max-h-96 ui:overflow-auto ui:duration-300">
{#each turnIntoCommand as command (command)}
{@const Icon = command.icon}
<DropdownMenu.Item
onclick={() => {
if (currentNode && currentNodePos) command.turnInto?.(editor, currentNodePos);
}}
>
<Icon />
<span>{command.tooltip}</span>
<DropdownMenu.Shortcut class="ui:bg-background ui:rounded ui:border ui:p-0.5"
>{command.shortCut}</DropdownMenu.Shortcut
>
</DropdownMenu.Item>
{/each}
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
</DropdownMenu.Group>
<DropdownMenu.Separator />
<DropdownMenu.Item onclick={handleAddNodeNext}>
<Plus />
Add Node
</DropdownMenu.Item>
<DropdownMenu.Item onclick={handleRemoveFormatting}>
<RemoveFormatting />
Remove Formatting
</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item onclick={handleDuplicate}>
<Duplicate />
Duplicate Node
@@ -94,6 +162,7 @@
<Clipboard />
Copy to clipboard
</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item onclick={handleDelete}>
<Delete class="ui:text-destructive" />
Delete Node
@@ -106,7 +106,11 @@
{:else}
{@const commandGroup = commands[cmd]}
{#each commandGroup as command (command)}
<ToolBarIcon {editor} {command} />
{#if command.name === 'paragraph'}
<span></span>
{:else}
<ToolBarIcon {editor} {command} />
{/if}
{/each}
{/if}
{/each}