add edra tiptap editor component

copied from edra library with svelte 5 fix for onTransaction callback
This commit is contained in:
Justin Edmund 2025-12-21 15:12:51 -08:00
parent 96ba26feba
commit 2792279f9a
61 changed files with 6201 additions and 187 deletions

View file

@ -68,20 +68,41 @@
},
"packageManager": "pnpm@10.15.1+sha512.34e538c329b5553014ca8e8f4535997f96180a1d0f614339357449935350d924e22f8614682191264ec33d1462ac21561aff97f6bb18065351c162c7e8f6de67",
"dependencies": {
"@floating-ui/dom": "^1.7.4",
"@friendofsvelte/tipex": "^0.0.9",
"@internationalized/date": "^3.10.0",
"@lucide/svelte": "^0.562.0",
"@tanstack/svelte-query": "^6.0.9",
"@tiptap/core": "^3.5.1",
"@tiptap/extension-highlight": "^3.5.1",
"@tiptap/extension-link": "^3.5.1",
"@tiptap/pm": "^3.5.1",
"@tiptap/starter-kit": "^3.5.1",
"@tiptap/core": "^3.14.0",
"@tiptap/extension-bubble-menu": "^3.14.0",
"@tiptap/extension-code-block-lowlight": "^3.14.0",
"@tiptap/extension-floating-menu": "3.14.0",
"@tiptap/extension-highlight": "^3.14.0",
"@tiptap/extension-image": "^3.14.0",
"@tiptap/extension-link": "^3.14.0",
"@tiptap/extension-list": "^3.14.0",
"@tiptap/extension-mathematics": "^3.14.0",
"@tiptap/extension-subscript": "^3.14.0",
"@tiptap/extension-superscript": "^3.14.0",
"@tiptap/extension-table": "^3.14.0",
"@tiptap/extension-text-align": "^3.14.0",
"@tiptap/extension-text-style": "^3.14.0",
"@tiptap/extension-typography": "^3.14.0",
"@tiptap/extensions": "^3.14.0",
"@tiptap/markdown": "^3.14.0",
"@tiptap/pm": "^3.14.0",
"@tiptap/starter-kit": "^3.14.0",
"@tiptap/suggestion": "^3.14.0",
"bits-ui": "^2.9.6",
"fluid-dnd": "^2.6.2",
"katex": "^0.16.27",
"lowlight": "^3.3.0",
"modern-normalize": "^3.0.1",
"runed": "^0.31.1",
"svelecte": "^5.3.0",
"svelte-sonner": "^1.0.7",
"svelte-tiptap": "^3.0.1",
"tiptap-extension-auto-joiner": "^0.1.3",
"wx-grid-data-provider": "^2.2.0",
"wx-svelte-grid": "^2.0.0",
"zod": "^4.1.5"

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1 @@
export { default as ToolBarCommands } from './toolbar-commands.js';

View file

@ -0,0 +1,503 @@
import type { EdraToolBarCommands } from './types.js';
import { isMac } from '../utils.js';
import Undo from '@lucide/svelte/icons/undo-2';
import Redo from '@lucide/svelte/icons/redo-2';
import Heading1 from '@lucide/svelte/icons/heading-1';
import Heading2 from '@lucide/svelte/icons/heading-2';
import Heading3 from '@lucide/svelte/icons/heading-3';
import Heading4 from '@lucide/svelte/icons/heading-4';
import Link from '@lucide/svelte/icons/link-2';
import Bold from '@lucide/svelte/icons/bold';
import Italic from '@lucide/svelte/icons/italic';
import Underline from '@lucide/svelte/icons/underline';
import StrikeThrough from '@lucide/svelte/icons/strikethrough';
import Quote from '@lucide/svelte/icons/quote';
import Code from '@lucide/svelte/icons/code';
import Superscript from '@lucide/svelte/icons/superscript';
import Subscript from '@lucide/svelte/icons/subscript';
import AlignLeft from '@lucide/svelte/icons/align-left';
import AlignCenter from '@lucide/svelte/icons/align-center';
import AlignRight from '@lucide/svelte/icons/align-right';
import AlighJustify from '@lucide/svelte/icons/align-justify';
import List from '@lucide/svelte/icons/list';
import ListOrdered from '@lucide/svelte/icons/list-ordered';
import ListChecks from '@lucide/svelte/icons/list-checks';
import Image from '@lucide/svelte/icons/image';
import Video from '@lucide/svelte/icons/video';
import Audio from '@lucide/svelte/icons/audio-lines';
import IFrame from '@lucide/svelte/icons/code-xml';
import Table from '@lucide/svelte/icons/table';
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';
const commands: Record<string, EdraToolBarCommands[]> = {
'undo-redo': [
{
icon: Undo,
name: 'undo',
tooltip: 'Undo',
shortCut: `${isMac ? '⌘' : 'Ctrl+'}Z`,
onClick: (editor) => {
editor.chain().focus().undo().run();
},
clickable: (editor) => {
return editor.can().undo();
}
},
{
icon: Redo,
name: 'redo',
tooltip: 'Redo',
shortCut: `${isMac ? '⌘' : 'Ctrl+'}Y`,
onClick: (editor) => {
editor.chain().focus().redo().run();
},
clickable: (editor) => {
return editor.can().redo();
}
}
],
headings: [
{
icon: Heading1,
name: 'h1',
tooltip: 'Heading 1',
shortCut: `${isMac ? '⌘⌥' : 'Ctrl+Alt+'}1`,
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 });
},
isActive: (editor) => {
return editor.isActive('heading', { level: 1 });
}
},
{
icon: Heading2,
name: 'h2',
tooltip: 'Heading 2',
shortCut: `${isMac ? '⌘⌥' : 'Ctrl+Alt+'}2`,
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 });
},
isActive: (editor) => {
return editor.isActive('heading', { level: 2 });
}
},
{
icon: Heading3,
name: 'h3',
tooltip: 'Heading 3',
shortCut: `${isMac ? '⌘⌥' : 'Ctrl+Alt+'}3`,
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 });
},
isActive: (editor) => {
return editor.isActive('heading', { level: 3 });
}
},
{
icon: Heading4,
name: 'h4',
tooltip: 'Heading 4',
shortCut: `${isMac ? '⌘⌥' : 'Ctrl+Alt+'}4`,
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 });
},
isActive: (editor) => {
return editor.isActive('heading', { level: 4 });
}
}
],
'text-formatting': [
{
icon: Link,
name: 'link',
tooltip: 'Link',
onClick: (editor) => {
if (editor.isActive('link')) {
editor.chain().focus().unsetLink().run();
} else {
const url = window.prompt('Enter the URL of the link:');
if (url) {
editor.chain().focus().toggleLink({ href: url }).run();
}
}
},
isActive: (editor) => {
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',
tooltip: 'Bold',
shortCut: `${isMac ? '⌘' : 'Ctrl+'}B`,
onClick: (editor) => {
editor.chain().focus().toggleBold().run();
},
turnInto: (editor, pos) => {
editor.chain().setNodeSelection(pos).setMark('bold').run();
},
clickable: (editor) => {
return editor.can().toggleBold();
},
isActive: (editor) => {
return editor.isActive('bold');
}
},
{
icon: Italic,
name: 'italic',
tooltip: 'Italic',
shortCut: `${isMac ? '⌘' : 'Ctrl+'}I`,
onClick: (editor) => {
editor.chain().focus().toggleItalic().run();
},
turnInto: (editor, pos) => {
editor.chain().setNodeSelection(pos).setMark('italic').run();
},
clickable: (editor) => {
return editor.can().toggleItalic();
},
isActive: (editor) => {
return editor.isActive('italic');
}
},
{
icon: Underline,
name: 'underline',
tooltip: 'Underline',
shortCut: `${isMac ? '⌘' : 'Ctrl+'}U`,
onClick: (editor) => {
editor.chain().focus().toggleUnderline().run();
},
turnInto: (editor, pos) => {
editor.chain().setNodeSelection(pos).setMark('underline').run();
},
clickable: (editor) => {
return editor.can().toggleUnderline();
},
isActive: (editor) => {
return editor.isActive('underline');
}
},
{
icon: StrikeThrough,
name: 'strikethrough',
tooltip: 'Strikethrough',
shortCut: `${isMac ? '⌘⇧' : 'Ctrl+Shift+'}S`,
onClick: (editor) => {
editor.chain().focus().toggleStrike().run();
},
turnInto: (editor, pos) => {
editor.chain().setNodeSelection(pos).setMark('strike').run();
},
clickable: (editor) => {
return editor.can().toggleStrike();
},
isActive: (editor) => {
return editor.isActive('strike');
}
},
{
icon: Quote,
name: 'blockQuote',
tooltip: 'BlockQuote',
shortCut: `${isMac ? '⌘⇧' : 'Ctrl+Shift+'}B`,
onClick: (editor) => {
editor.chain().focus().toggleBlockquote().run();
},
turnInto: (editor, pos) => {
editor.chain().setNodeSelection(pos).toggleBlockquote().run();
},
clickable: (editor) => {
return editor.can().toggleBlockquote();
},
isActive: (editor) => {
return editor.isActive('blockquote');
}
},
{
icon: Code,
name: 'code',
tooltip: 'Inline Code',
shortCut: `${isMac ? '⌘' : 'Ctrl+'}E`,
onClick: (editor) => {
editor.chain().focus().toggleCode().run();
},
turnInto: (editor, pos) => {
editor.chain().setNodeSelection(pos).toggleCodeBlock().run();
},
clickable: (editor) => {
return editor.can().toggleCode();
},
isActive: (editor) => {
return editor.isActive('code');
}
},
{
icon: Superscript,
name: 'superscript',
tooltip: 'Superscript',
shortCut: `${isMac ? '⌘' : 'Ctrl+'}.`,
onClick: (editor) => {
editor.chain().focus().toggleSuperscript().run();
},
clickable: (editor) => {
return editor.can().toggleSuperscript();
},
isActive: (editor) => {
return editor.isActive('superscript');
}
},
{
icon: Subscript,
name: 'subscript',
tooltip: 'Subscript',
shortCut: `${isMac ? '⌘' : 'Ctrl+'},`,
onClick: (editor) => {
editor.chain().focus().toggleSubscript().run();
},
clickable: (editor) => {
return editor.can().toggleSubscript();
},
isActive: (editor) => {
return editor.isActive('subscript');
}
}
],
alignment: [
{
icon: AlignLeft,
name: 'align-left',
tooltip: 'Align Left',
shortCut: `${isMac ? '⌘⇧' : 'Ctrl+Shift+'}L`,
onClick: (editor) => {
editor.chain().focus().toggleTextAlign('left').run();
},
clickable: (editor) => {
return editor.can().toggleTextAlign('left');
},
isActive: (editor) => editor.isActive({ textAlign: 'left' })
},
{
icon: AlignCenter,
name: 'align-center',
tooltip: 'Align Center',
shortCut: `${isMac ? '⌘⇧' : 'Ctrl+Shift+'}E`,
onClick: (editor) => {
editor.chain().focus().toggleTextAlign('center').run();
},
clickable: (editor) => {
return editor.can().toggleTextAlign('center');
},
isActive: (editor) => editor.isActive({ textAlign: 'center' })
},
{
icon: AlignRight,
name: 'align-right',
tooltip: 'Align Right',
shortCut: `${isMac ? '⌘⇧' : 'Ctrl+Shift+'}R`,
onClick: (editor) => {
editor.chain().focus().toggleTextAlign('right').run();
},
clickable: (editor) => {
return editor.can().toggleTextAlign('right');
},
isActive: (editor) => editor.isActive({ textAlign: 'right' })
},
{
icon: AlighJustify,
name: 'align-justify',
tooltip: 'Align Justify',
shortCut: `${isMac ? '⌘⇧' : 'Ctrl+Shift+'}J`,
onClick: (editor) => {
editor.chain().focus().toggleTextAlign('justify').run();
},
clickable: (editor) => {
return editor.can().toggleTextAlign('justify');
},
isActive: (editor) => editor.isActive({ textAlign: 'justify' })
}
],
lists: [
{
icon: List,
name: 'bulletList',
tooltip: 'Bullet List',
shortCut: `${isMac ? '⌘⇧' : 'Ctrl+Shift+'}8`,
onClick: (editor) => {
editor.chain().focus().toggleBulletList().run();
},
turnInto: (editor, pos) => {
editor.chain().setNodeSelection(pos).toggleBulletList().run();
},
isActive: (editor) => editor.isActive('bulletList')
},
{
icon: ListOrdered,
name: 'orderedList',
tooltip: 'Ordered List',
shortCut: `${isMac ? '⌘⇧' : 'Ctrl+Shift+'}7`,
onClick: (editor) => {
editor.chain().focus().toggleOrderedList().run();
},
turnInto: (editor, pos) => {
editor.chain().setNodeSelection(pos).toggleOrderedList().run();
},
clickable: (editor) => {
return editor.can().toggleOrderedList();
},
isActive: (editor) => {
return editor.isActive('orderedList');
}
},
{
icon: ListChecks,
name: 'taskList',
tooltip: 'Task List',
shortCut: `${isMac ? '⌘⇧' : 'Ctrl+Shift+'}9`,
onClick: (editor) => {
editor.chain().focus().toggleTaskList().run();
},
turnInto: (editor, pos) => {
editor.chain().setNodeSelection(pos).toggleTaskList().run();
},
clickable: (editor) => {
return editor.can().toggleTaskList();
},
isActive: (editor) => {
return editor.isActive('taskList');
}
}
],
media: [
{
icon: Image,
name: 'image-placeholder',
tooltip: 'Image Placeholder',
onClick: (editor) => {
editor.chain().focus().insertImagePlaceholder().run();
},
isActive: (editor) => editor.isActive('image-placeholder')
},
{
icon: Video,
name: 'video-placeholder',
tooltip: 'Video Placeholder',
onClick: (editor) => {
editor.chain().focus().insertVideoPlaceholder().run();
},
isActive: (editor) => editor.isActive('video-placeholder')
},
{
icon: Audio,
name: 'audio-placeholder',
tooltip: 'Audio Placeholder',
onClick: (editor) => {
editor.chain().focus().insertAudioPlaceholder().run();
},
isActive: (editor) => editor.isActive('audio-placeholder')
},
{
icon: IFrame,
name: 'iframe-placeholder',
tooltip: 'IFrame Placeholder',
onClick: (editor) => {
editor.chain().focus().insertIFramePlaceholder().run();
},
isActive: (editor) => editor.isActive('iframe-placeholder')
}
],
table: [
{
icon: Table,
name: 'table',
tooltip: 'Table',
onClick: (editor) => {
if (editor.isActive('table')) {
const del = confirm('Do you really want to delete this table??');
if (del) {
editor.chain().focus().deleteTable().run();
return;
}
}
editor.chain().focus().insertTable({ cols: 3, rows: 3, withHeaderRow: false }).run();
},
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')
}
]
};
export default commands;

View file

@ -0,0 +1,13 @@
import type { Editor } from '@tiptap/core';
import { Icon } from '@lucide/svelte';
export interface EdraToolBarCommands {
name: string;
icon: typeof Icon;
tooltip?: string;
shortCut?: string;
onClick?: (editor: Editor) => void;
turnInto?: (editor: Editor, pos: number) => void;
isActive?: (editor: Editor) => boolean;
clickable?: (editor: Editor) => boolean;
}

View file

@ -0,0 +1,67 @@
<script lang="ts">
import { onMount, type Snippet } from 'svelte';
import { BubbleMenuPlugin, type BubbleMenuPluginProps } from '@tiptap/extension-bubble-menu';
import type { Editor } from '@tiptap/core';
type Optional<T, K extends keyof T> = Pick<Partial<T>, K> & Omit<T, K>;
interface Props
extends Optional<Omit<Optional<BubbleMenuPluginProps, 'pluginKey'>, 'element'>, 'editor'> {
editor?: Editor;
children?: Snippet<[]>;
class?: string;
style?: string;
pluginKey?: string;
updateDelay?: number;
resizeDelay?: number;
}
let {
editor,
shouldShow = null,
class: className = '',
style = '',
children,
updateDelay,
resizeDelay,
pluginKey = 'bubbleMenu',
options,
...restProps
}: Props = $props();
let element = $state<HTMLElement>();
onMount(() => {
if (!element) return;
element.style.position = 'absolute';
element.style.visibility = 'hidden';
if (!editor || editor.isDestroyed) {
console.warn('BubbleMenu component does not have editor prop or editor is destroyed.');
return;
}
const plugin = BubbleMenuPlugin({
pluginKey,
editor,
element,
shouldShow,
updateDelay,
resizeDelay,
options
});
editor.registerPlugin(plugin);
return () => {
if (editor && !editor.isDestroyed) {
editor.unregisterPlugin(pluginKey);
}
};
});
</script>
<div bind:this={element} class={`bubble-menu-wrapper ${className}`} {style} {...restProps}>
{@render children?.()}
</div>

View file

@ -0,0 +1,31 @@
<script lang="ts">
import type { Editor } from '@tiptap/core';
import { onMount } from 'svelte';
import GripVertical from '@lucide/svelte/icons/grip-vertical';
import { DragHandlePlugin } from '../extensions/drag-handle/index.js';
interface Props {
editor: Editor;
}
const { editor }: Props = $props();
const pluginKey = 'globalDragHandle';
onMount(() => {
const plugin = DragHandlePlugin({
pluginKey: pluginKey,
dragHandleWidth: 20,
scrollTreshold: 100,
dragHandleSelector: '.drag-handle',
excludedTags: ['pre', 'code', 'table p'],
customNodes: []
});
editor.registerPlugin(plugin);
return () => editor.unregisterPlugin(pluginKey);
});
</script>
<div class="drag-handle">
<GripVertical />
</div>

View file

@ -0,0 +1,30 @@
<script lang="ts">
import type { Icon } from '@lucide/svelte';
import type { Snippet } from 'svelte';
import { NodeViewWrapper } from 'svelte-tiptap';
interface Props {
icon?: typeof Icon;
title?: string;
onClick?: () => void;
class?: string;
children?: Snippet<[]>;
}
const { icon, title, onClick, class: className = '', children }: Props = $props();
</script>
<NodeViewWrapper
as="div"
contenteditable="false"
class={`media-placeholder ${className}`}
onclick={onClick}
style="user-select: none;"
>
{#if !children && icon && title}
{@const Icon = icon}
<Icon />
<div contenteditable="false">{title}</div>
{/if}
{@render children?.()}
</NodeViewWrapper>

View file

@ -0,0 +1,493 @@
/* Base TipTap Editor Styles with Light/Dark Theme Support */
.tiptap :first-child {
margin-top: 0;
}
/* For Placeholder */
.tiptap .is-empty:not(blockquote.is-empty)::before {
pointer-events: none;
float: left;
height: 0;
color: var(--color-muted-foreground);
content: attr(data-placeholder);
}
/* Heading Styles */
.tiptap h1,
.tiptap h2,
.tiptap h3,
.tiptap h4,
.tiptap h5,
.tiptap h6 {
line-height: 1.2;
margin-top: 1rem;
text-wrap: pretty;
}
.tiptap h1,
.tiptap h2 {
margin-top: 1rem;
margin-bottom: 1rem;
}
.tiptap h1 {
scroll-margin: 5rem;
font-size: 2.25rem;
line-height: 2.5rem;
font-weight: 800;
letter-spacing: -0.015em;
}
.tiptap h2 {
font-size: 1.5rem;
font-weight: 700;
}
.tiptap h3 {
font-size: 1.25rem;
font-weight: 600;
}
.tiptap h4,
.tiptap h5,
.tiptap h6 {
font-size: 1rem;
font-weight: 600;
}
.tiptap blockquote {
margin: 1rem 0rem;
padding: 0.5rem 0;
padding-left: 0.75rem;
font-style: italic;
color: var(--blockquote-color);
position: relative;
}
.tiptap blockquote::before {
content: ' ';
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 0.4rem;
background-color: var(--blockquote-border);
border-radius: 0.5rem;
}
.tiptap blockquote p {
margin: 0;
}
/* Horizontal Rule */
.tiptap hr {
border: none;
border-top: 1px solid var(--border-color);
margin: 1rem 0;
}
/* Inline Code */
.tiptap code:not(pre code) {
background-color: var(--code-bg);
border-radius: 0.25rem;
padding: 0.2rem 0.3rem;
font-family: monospace;
font-size: 0.875rem;
font-weight: bold;
}
/* List Styling */
.tiptap ul,
.tiptap ol {
padding: 0 1rem;
margin: 0.5rem 1rem 0.5rem 0.4rem;
}
.tiptap ul li p,
.tiptap ol li p {
margin-top: 0.25rem;
margin-bottom: 0.25rem;
}
/* Task List Styling */
.tiptap ul[data-type='taskList'] {
list-style: none;
margin: 0;
padding: 0;
}
.tiptap ul[data-type='taskList'] li {
align-items: flex-start;
display: flex;
}
.tiptap ul[data-type='taskList'] li > label {
flex: 0 0 auto;
margin-right: 0.5rem;
user-select: none;
}
.tiptap ul[data-type='taskList'] li > div {
flex: 1 1 auto;
}
.tiptap ul[data-type='taskList'] input[type='checkbox'] {
cursor: pointer;
}
.tiptap ul[data-type='taskList'] ul[data-type='taskList'] {
margin: 0;
}
ul[data-type='taskList'] li[data-checked='true'] div {
color: var(--task-completed-color);
text-decoration: line-through;
}
input[type='checkbox'] {
position: relative;
top: 0.25rem;
margin: 0;
display: grid;
place-content: center;
cursor: pointer;
width: 1rem;
height: 1rem;
border-radius: 0.25rem;
}
/* Color Swatches */
.color {
white-space: nowrap;
}
.color::before {
margin-bottom: 0.15rem;
margin-right: 0.1rem;
display: inline-block;
width: 1rem;
height: 1rem;
border-radius: 0.25rem;
border: 1px solid var(--border-color);
vertical-align: middle;
background-color: var(--color);
content: ' ';
}
/* Code Block Styling */
.tiptap pre {
margin: 0;
display: flex;
height: fit-content;
overflow: auto;
background-color: transparent;
padding: 0;
}
.tiptap pre code {
flex: 1;
border-radius: 0 !important;
background-color: transparent;
padding: 0;
color: inherit;
font-family: 'JetBrains Mono', monospace, Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono';
}
/* Drag Handle Styling */
.drag-handle {
position: fixed;
z-index: 50;
width: 1.5rem;
height: 1.5rem;
display: flex;
padding-right: 0.5rem;
flex-direction: column;
justify-content: center;
align-items: center;
cursor: grab;
opacity: 100;
transition-property: all;
transition-duration: 300ms;
transition-timing-function: cubic-bezier(0.4, 0, 1, 1);
color: var(--border-color-hover);
}
.drag-handle:active {
cursor: grabbing;
}
.drag-handle.hide {
opacity: 0;
pointer-events: none;
}
@media screen and (max-width: 600px) {
.drag-handle {
display: none;
pointer-events: none;
}
}
.drag-handle svg {
height: 1rem;
width: 1rem;
}
/* Math Equations (KaTeX) */
.katex:hover {
background-color: var(--code-bg);
}
.katex.result {
border-bottom: 1px dashed var(--highlight-border);
background-color: var(--highlight-color);
}
/* Table Styling */
.ProseMirror .tableWrapper {
margin: 0;
overflow: auto;
padding: 1rem;
}
.ProseMirror table {
margin-top: 1rem;
margin-bottom: 1rem;
box-sizing: border-box;
width: 100%;
border-collapse: collapse;
border-radius: 0.25rem;
border: 1px solid var(--table-border);
}
.ProseMirror table td,
.ProseMirror table th {
position: relative;
min-width: 100px;
border: 1px solid var(--table-border);
padding: 0.5rem;
text-align: left;
vertical-align: top;
}
.ProseMirror table td:first-of-type:not(a),
.ProseMirror table th:first-of-type:not(a) {
margin-top: 0;
}
.ProseMirror table td p,
.ProseMirror table th p {
margin: 0;
}
.ProseMirror table td p + p,
.ProseMirror table th p + p {
margin-top: 0.75rem;
}
.ProseMirror table th {
font-weight: bold;
}
.ProseMirror table .column-resize-handle {
pointer-events: none;
position: absolute;
top: 0;
right: -0.25rem;
bottom: -2px;
display: flex;
width: 0.5rem;
background-color: var(--table-border);
}
.ProseMirror table .column-resize-handle::before {
content: '';
margin-left: 0.5rem;
height: 100%;
width: 1px;
background-color: var(--table-border);
}
.ProseMirror table .selectedCell {
border-style: double;
border-color: var(--table-border);
background-color: var(--table-bg-selected);
}
.ProseMirror table .grip-column,
.ProseMirror table .grip-row {
position: absolute;
z-index: 10;
display: flex;
cursor: pointer;
align-items: center;
justify-content: center;
background-color: var(--table-bg-selected);
}
.ProseMirror table .grip-column {
top: -0.75rem;
left: 0;
margin-left: -1px;
height: 0.75rem;
width: calc(100% + 1px);
border-left: 1px solid var(--table-border);
}
.ProseMirror table .grip-column:hover::before,
.ProseMirror table .grip-column.selected::before {
content: '';
width: 0.625rem;
}
.ProseMirror table .grip-column:hover {
background-color: var(--table-bg-hover);
}
.ProseMirror table .grip-column:hover::before {
border-bottom: 2px dotted var(--border-color-hover);
}
.ProseMirror table .grip-column.first {
border-top-left-radius: 0.125rem;
border-color: transparent;
}
.ProseMirror table .grip-column.last {
border-top-right-radius: 0.125rem;
}
.ProseMirror table .grip-column.selected {
border-color: var(--table-border);
background-color: var(--table-bg-hover);
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
}
.ProseMirror table .grip-column.selected::before {
border-bottom: 2px dotted var(--border-color-hover);
}
.ProseMirror table .grip-row {
left: -0.75rem;
top: 0;
margin-top: -1px;
height: calc(100% + 1px);
width: 0.75rem;
border-top: 1px solid var(--table-border);
}
.ProseMirror table .grip-row:hover::before,
.ProseMirror table .grip-row.selected::before {
content: '';
height: 0.625rem;
}
.ProseMirror table .grip-row:hover {
background-color: var(--table-bg-hover);
}
.ProseMirror table .grip-row:hover::before {
border-left: 2px dotted var(--border-color-hover);
}
.ProseMirror table .grip-row.first {
border-top-left-radius: 0.125rem;
border-color: transparent;
}
.ProseMirror table .grip-row.last {
border-bottom-left-radius: 0.125rem;
}
.ProseMirror table .grip-row.selected {
border-color: var(--table-border);
background-color: var(--table-bg-hover);
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
}
.ProseMirror table .grip-row.selected::before {
border-left: 2px dotted var(--border-color-hover);
}
.tiptap .search-result {
background-color: var(--search-result-bg);
color: black;
}
.tiptap .search-result-current {
background-color: var(--search-result-current-bg);
color: black;
}
.code-wrapper {
background-color: var(--codeblock-bg);
border-radius: 0.5rem;
padding: 1rem;
position: relative;
height: fit-content;
width: 100%;
margin: 0.5rem 0;
}
.code-wrapper-tile {
opacity: 0;
width: 100%;
display: flex;
position: absolute;
top: 0;
padding: 0.25rem;
right: 0;
align-items: center;
justify-content: space-between;
gap: 1rem;
transition: opacity 0.2s ease-in-out;
}
.code-wrapper:hover .code-wrapper-tile {
opacity: 1;
}
.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;
}

View file

@ -0,0 +1,130 @@
import { Editor, type Extensions, type EditorOptions, type Content } from '@tiptap/core';
import StarterKit from '@tiptap/starter-kit';
import { getHandlePaste } from './utils.js';
import Subscript from '@tiptap/extension-subscript';
import Superscript from '@tiptap/extension-superscript';
import Typography from '@tiptap/extension-typography';
import { ColorHighlighter } from './extensions/ColorHighlighter.js';
import { FontSize, TextStyle, Color } from '@tiptap/extension-text-style';
import TextAlign from '@tiptap/extension-text-align';
import Highlight from '@tiptap/extension-highlight';
import SearchAndReplace from './extensions/FindAndReplace.js';
import { TaskItem, TaskList } from '@tiptap/extension-list';
import { Table, TableCell, TableRow, TableHeader } from './extensions/table/index.js';
import { Placeholder } from '@tiptap/extensions';
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.js';
export default (
element?: HTMLElement,
content?: Content,
extensions?: Extensions,
options?: Partial<EditorOptions>
) => {
const editor = new Editor({
element,
content,
extensions: [
StarterKit.configure({
orderedList: {
HTMLAttributes: {
class: 'list-decimal'
}
},
bulletList: {
HTMLAttributes: {
class: 'list-disc'
}
},
heading: {
levels: [1, 2, 3, 4]
},
link: {
openOnClick: false,
autolink: true,
linkOnPaste: true
},
codeBlock: false
}),
Highlight.configure({
multicolor: true
}),
Placeholder.configure({
emptyEditorClass: 'is-empty',
// Use a placeholder:
// Use different placeholders depending on the node type:
placeholder: ({ node }) => {
if (node.type.name === 'heading') {
return 'Whats the title?';
} else if (node.type.name === 'paragraph') {
return 'Press / or write something ...';
}
return '';
}
}),
Color,
Subscript,
Superscript,
Typography,
ColorHighlighter,
TextStyle,
FontSize,
TextAlign.configure({
types: ['heading', 'paragraph']
}),
TaskList,
TaskItem.configure({
nested: true
}),
SearchAndReplace,
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,
...(extensions ?? [])
],
...options
});
editor.setOptions({
editorProps: {
handlePaste: getHandlePaste(editor)
}
});
return editor;
};

View file

@ -0,0 +1,28 @@
import { Extension } from '@tiptap/core';
import { Plugin } from '@tiptap/pm/state';
import { findColors } from '../utils.js';
export const ColorHighlighter = Extension.create({
name: 'colorHighlighter',
addProseMirrorPlugins() {
return [
new Plugin({
state: {
init(_, { doc }) {
return findColors(doc);
},
apply(transaction, oldState) {
return transaction.docChanged ? findColors(transaction.doc) : oldState;
}
},
props: {
decorations(state) {
return this.getState(state);
}
}
})
];
}
});

View file

@ -0,0 +1,408 @@
// MIT License
// Copyright (c) 2023 - 2024 Jeet Mandaliya (Github Username: sereneinserenade)
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
import { Extension, type Range, type Dispatch } from '@tiptap/core';
import { Decoration, DecorationSet } from '@tiptap/pm/view';
import { Plugin, PluginKey, type EditorState, type Transaction } from '@tiptap/pm/state';
import { Node as PMNode } from '@tiptap/pm/model';
declare module '@tiptap/core' {
interface Commands<ReturnType> {
search: {
/**
* @description Set search term in extension.
*/
setSearchTerm: (searchTerm: string) => ReturnType;
/**
* @description Set replace term in extension.
*/
setReplaceTerm: (replaceTerm: string) => ReturnType;
/**
* @description Set case sensitivity in extension.
*/
setCaseSensitive: (caseSensitive: boolean) => ReturnType;
/**
* @description Reset current search result to first instance.
*/
resetIndex: () => ReturnType;
/**
* @description Find next instance of search result.
*/
nextSearchResult: () => ReturnType;
/**
* @description Find previous instance of search result.
*/
previousSearchResult: () => ReturnType;
/**
* @description Replace first instance of search result with given replace term.
*/
replace: () => ReturnType;
/**
* @description Replace all instances of search result with given replace term.
*/
replaceAll: () => ReturnType;
};
}
}
interface TextNodesWithPosition {
text: string;
pos: number;
}
const getRegex = (s: string, disableRegex: boolean, caseSensitive: boolean): RegExp => {
return RegExp(
disableRegex ? s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') : s,
caseSensitive ? 'gu' : 'gui'
);
};
interface ProcessedSearches {
decorationsToReturn: DecorationSet;
results: Range[];
}
function processSearches(
doc: PMNode,
searchTerm: RegExp,
searchResultClass: string,
resultIndex: number
): ProcessedSearches {
const decorations: Decoration[] = [];
const results: Range[] = [];
let textNodesWithPosition: TextNodesWithPosition[] = [];
let index = 0;
if (!searchTerm) {
return {
decorationsToReturn: DecorationSet.empty,
results: []
};
}
doc?.descendants((node, pos) => {
if (node.isText) {
if (textNodesWithPosition[index]) {
textNodesWithPosition[index] = {
text: textNodesWithPosition[index].text + node.text,
pos: textNodesWithPosition[index].pos
};
} else {
textNodesWithPosition[index] = {
text: `${node.text}`,
pos
};
}
} else {
index += 1;
}
});
textNodesWithPosition = textNodesWithPosition.filter(Boolean);
for (const element of textNodesWithPosition) {
const { text, pos } = element;
const matches = Array.from(text.matchAll(searchTerm)).filter(([matchText]) => matchText.trim());
for (const m of matches) {
if (m[0] === '') break;
if (m.index !== undefined) {
results.push({
from: pos + m.index,
to: pos + m.index + m[0].length
});
}
}
}
for (let i = 0; i < results.length; i += 1) {
const r = results[i];
const className =
i === resultIndex ? `${searchResultClass} ${searchResultClass}-current` : searchResultClass;
const decoration: Decoration = Decoration.inline(r.from, r.to, {
class: className
});
decorations.push(decoration);
}
return {
decorationsToReturn: DecorationSet.create(doc, decorations),
results
};
}
const replace = (
replaceTerm: string,
results: Range[],
{ state, dispatch }: { state: EditorState; dispatch: Dispatch }
) => {
const firstResult = results[0];
if (!firstResult) return;
const { from, to } = results[0];
if (dispatch) dispatch(state.tr.insertText(replaceTerm, from, to));
};
const rebaseNextResult = (
replaceTerm: string,
index: number,
lastOffset: number,
results: Range[]
): [number, Range[]] | null => {
const nextIndex = index + 1;
if (!results[nextIndex]) return null;
const { from: currentFrom, to: currentTo } = results[index];
const offset = currentTo - currentFrom - replaceTerm.length + lastOffset;
const { from, to } = results[nextIndex];
results[nextIndex] = {
to: to - offset,
from: from - offset
};
return [offset, results];
};
const replaceAll = (
replaceTerm: string,
results: Range[],
{ tr, dispatch }: { tr: Transaction; dispatch: Dispatch }
) => {
let offset = 0;
let resultsCopy = results.slice();
if (!resultsCopy.length) return;
for (let i = 0; i < resultsCopy.length; i += 1) {
const { from, to } = resultsCopy[i];
tr.insertText(replaceTerm, from, to);
const rebaseNextResultResponse = rebaseNextResult(replaceTerm, i, offset, resultsCopy);
if (!rebaseNextResultResponse) continue;
offset = rebaseNextResultResponse[0];
resultsCopy = rebaseNextResultResponse[1];
}
if (dispatch) {
dispatch(tr);
}
};
export const searchAndReplacePluginKey = new PluginKey('searchAndReplacePlugin');
export interface SearchAndReplaceOptions {
searchResultClass: string;
disableRegex: boolean;
}
export interface SearchAndReplaceStorage {
searchTerm: string;
replaceTerm: string;
results: Range[];
lastSearchTerm: string;
caseSensitive: boolean;
lastCaseSensitive: boolean;
resultIndex: number;
lastResultIndex: number;
}
export const SearchAndReplace = Extension.create<SearchAndReplaceOptions, SearchAndReplaceStorage>({
name: 'searchAndReplace',
addOptions() {
return {
searchResultClass: 'search-result',
disableRegex: true
};
},
addStorage() {
return {
searchTerm: '',
replaceTerm: '',
results: [],
lastSearchTerm: '',
caseSensitive: false,
lastCaseSensitive: false,
resultIndex: 0,
lastResultIndex: 0
};
},
addCommands() {
return {
setSearchTerm:
(searchTerm: string) =>
({ editor }) => {
editor.storage.searchAndReplace.searchTerm = searchTerm;
return false;
},
setReplaceTerm:
(replaceTerm: string) =>
({ editor }) => {
editor.storage.searchAndReplace.replaceTerm = replaceTerm;
return false;
},
setCaseSensitive:
(caseSensitive: boolean) =>
({ editor }) => {
editor.storage.searchAndReplace.caseSensitive = caseSensitive;
return false;
},
resetIndex:
() =>
({ editor }) => {
editor.storage.searchAndReplace.resultIndex = 0;
return false;
},
nextSearchResult:
() =>
({ editor }) => {
const { results, resultIndex } = editor.storage.searchAndReplace;
const nextIndex = resultIndex + 1;
if (results[nextIndex]) {
editor.storage.searchAndReplace.resultIndex = nextIndex;
} else {
editor.storage.searchAndReplace.resultIndex = 0;
}
return false;
},
previousSearchResult:
() =>
({ editor }) => {
const { results, resultIndex } = editor.storage.searchAndReplace;
const prevIndex = resultIndex - 1;
if (results[prevIndex]) {
editor.storage.searchAndReplace.resultIndex = prevIndex;
} else {
editor.storage.searchAndReplace.resultIndex = results.length - 1;
}
return false;
},
replace:
() =>
({ editor, state, dispatch }) => {
const { replaceTerm, results } = editor.storage.searchAndReplace;
replace(replaceTerm, results, { state, dispatch });
return false;
},
replaceAll:
() =>
({ editor, tr, dispatch }) => {
const { replaceTerm, results } = editor.storage.searchAndReplace;
replaceAll(replaceTerm, results, { tr, dispatch });
return false;
}
};
},
addProseMirrorPlugins() {
const editor = this.editor;
const { searchResultClass, disableRegex } = this.options;
const setLastSearchTerm = (t: string) => (editor.storage.searchAndReplace.lastSearchTerm = t);
const setLastCaseSensitive = (t: boolean) =>
(editor.storage.searchAndReplace.lastCaseSensitive = t);
const setLastResultIndex = (t: number) => (editor.storage.searchAndReplace.lastResultIndex = t);
return [
new Plugin({
key: searchAndReplacePluginKey,
state: {
init: () => DecorationSet.empty,
apply({ doc, docChanged }, oldState) {
const {
searchTerm,
lastSearchTerm,
caseSensitive,
lastCaseSensitive,
resultIndex,
lastResultIndex
} = editor.storage.searchAndReplace;
if (
!docChanged &&
lastSearchTerm === searchTerm &&
lastCaseSensitive === caseSensitive &&
lastResultIndex === resultIndex
)
return oldState;
setLastSearchTerm(searchTerm);
setLastCaseSensitive(caseSensitive);
setLastResultIndex(resultIndex);
if (!searchTerm) {
editor.storage.searchAndReplace.results = [];
return DecorationSet.empty;
}
const { decorationsToReturn, results } = processSearches(
doc,
getRegex(searchTerm, disableRegex, caseSensitive),
searchResultClass,
resultIndex
);
editor.storage.searchAndReplace.results = results;
return decorationsToReturn;
}
},
props: {
decorations(state) {
return this.getState(state);
}
}
})
];
}
});
export default SearchAndReplace;

View file

@ -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 '';
}
})
];
}
});

View file

@ -0,0 +1,34 @@
import { SvelteNodeViewRenderer } from 'svelte-tiptap';
import { Audio } from './AudioExtension.js';
import type { NodeViewProps } from '@tiptap/core';
import type { Component } from 'svelte';
export const AudioExtended = (content: Component<NodeViewProps>) =>
Audio.extend({
addAttributes() {
return {
src: {
default: null
},
alt: {
default: null
},
title: {
default: null
},
width: {
default: '100%'
},
height: {
default: null
},
align: {
default: 'left'
}
};
},
addNodeView: () => {
return SvelteNodeViewRenderer(content);
}
});

View file

@ -0,0 +1,147 @@
import { Node, nodeInputRule } from '@tiptap/core';
import { Plugin, PluginKey } from '@tiptap/pm/state';
export interface AudioOptions {
HTMLAttributes: Record<string, unknown>;
}
declare module '@tiptap/core' {
interface Commands<ReturnType> {
audio: {
/**
* Set a audio node
*/
setAudio: (src: string) => ReturnType;
/**
* Toggle a audio
*/
toggleAudio: (src: string) => ReturnType;
/**
* Remove a audio
*/
removeAudio: () => ReturnType;
};
}
}
const AUDIO_INPUT_REGEX = /!\[(.+|:?)]\((\S+)(?:(?:\s+)["'](\S+)["'])?\)/;
export const Audio = Node.create<AudioOptions>({
name: 'audio',
group: 'block',
draggable: true,
isolating: true,
atom: true,
addOptions() {
return {
HTMLAttributes: {}
};
},
addAttributes() {
return {
src: {
default: null,
parseHTML: (el) => (el as HTMLSpanElement).getAttribute('src'),
renderHTML: (attrs) => ({ src: attrs.src })
}
};
},
parseHTML() {
return [
{
tag: 'audio',
getAttrs: (el) => ({ src: (el as HTMLAudioElement).getAttribute('src') })
}
];
},
renderHTML({ HTMLAttributes }) {
return [
'audio',
{ controls: 'true', style: 'width: 100%;', ...HTMLAttributes },
['source', HTMLAttributes]
];
},
addCommands() {
return {
setAudio:
(src: string) =>
({ commands }) =>
commands.insertContent(
`<audio controls autoplay="false" style="width: 100%;" src="${src}"></audio>`
),
toggleAudio:
() =>
({ commands }) =>
commands.toggleNode(this.name, 'paragraph'),
removeAudio:
() =>
({ commands }) =>
commands.deleteNode(this.name)
};
},
addInputRules() {
return [
nodeInputRule({
find: AUDIO_INPUT_REGEX,
type: this.type,
getAttributes: (match) => {
const [, , src] = match;
return { src };
}
})
];
},
addProseMirrorPlugins() {
return [
new Plugin({
key: new PluginKey('audioDropPlugin'),
props: {
handleDOMEvents: {
drop(view, event) {
const {
state: { schema, tr },
dispatch
} = view;
const hasFiles =
event.dataTransfer && event.dataTransfer.files && event.dataTransfer.files.length;
if (!hasFiles) return false;
const audios = Array.from(event.dataTransfer.files).filter((file) =>
/audio/i.test(file.type)
);
if (audios.length === 0) return false;
event.preventDefault();
const coordinates = view.posAtCoords({ left: event.clientX, top: event.clientY });
audios.forEach((audio) => {
const reader = new FileReader();
reader.onload = (readerEvent) => {
const node = schema.nodes.audio.create({ src: readerEvent.target?.result });
if (coordinates && typeof coordinates.pos === 'number') {
const transaction = tr.insert(coordinates?.pos, node);
dispatch(transaction);
}
};
reader.readAsDataURL(audio);
});
return true;
}
}
}
})
];
}
});

View file

@ -0,0 +1,64 @@
import { Editor, Node, mergeAttributes, type CommandProps, type NodeViewProps } from '@tiptap/core';
import type { Component } from 'svelte';
import { SvelteNodeViewRenderer } from 'svelte-tiptap';
export interface AudioPlaceholderOptions {
HTMLAttributes: Record<string, object>;
onDrop: (files: File[], editor: Editor) => void;
onDropRejected?: (files: File[], editor: Editor) => void;
onEmbed: (url: string, editor: Editor) => void;
allowedMimeTypes?: Record<string, string[]>;
maxFiles?: number;
maxSize?: number;
}
declare module '@tiptap/core' {
interface Commands<ReturnType> {
audioPlaceholder: {
/**
* Inserts an audio placeholder
*/
insertAudioPlaceholder: () => ReturnType;
};
}
}
export const AudioPlaceholder = (
component: Component<NodeViewProps>
): Node<AudioPlaceholderOptions> =>
Node.create<AudioPlaceholderOptions>({
name: 'audio-placeholder',
addOptions() {
return {
HTMLAttributes: {},
onDrop: () => {},
onDropRejected: () => {},
onEmbed: () => {}
};
},
parseHTML() {
return [{ tag: `div[data-type="${this.name}"]` }];
},
renderHTML({ HTMLAttributes }) {
return ['div', mergeAttributes(HTMLAttributes)];
},
group: 'block',
draggable: true,
atom: true,
content: 'inline*',
isolating: true,
addNodeView() {
return SvelteNodeViewRenderer(component);
},
addCommands() {
return {
insertAudioPlaceholder: () => (props: CommandProps) => {
return props.commands.insertContent({
type: 'audio-placeholder'
});
}
};
}
});

View file

@ -0,0 +1,28 @@
import { Slice } from '@tiptap/pm/model';
import { EditorView } from '@tiptap/pm/view';
import * as pmView from '@tiptap/pm/view';
function getPmView() {
try {
return pmView;
} catch (error) {
console.error(error);
return null;
}
}
export function serializeForClipboard(view: EditorView, slice: Slice) {
// Newer Tiptap/ProseMirror
if (view && typeof view.serializeForClipboard === 'function') {
return view.serializeForClipboard(slice);
}
// Older version fallback
const proseMirrorView = getPmView();
if (proseMirrorView && typeof proseMirrorView?.__serializeForClipboard === 'function') {
return proseMirrorView.__serializeForClipboard(view, slice);
}
throw new Error('No supported clipboard serialization method found.');
}

View file

@ -0,0 +1,381 @@
import { Extension } from '@tiptap/core';
import { NodeSelection, Plugin, PluginKey, TextSelection } from '@tiptap/pm/state';
import { Fragment, Slice, Node } from '@tiptap/pm/model';
import { EditorView } from '@tiptap/pm/view';
import { serializeForClipboard } from './ClipboardSerializer.js';
export interface GlobalDragHandleOptions {
/**
* The width of the drag handle
*/
dragHandleWidth: number;
/**
* The treshold for scrolling
*/
scrollTreshold: number;
/*
* The css selector to query for the drag handle. (eg: '.custom-handle').
* If handle element is found, that element will be used as drag handle. If not, a default handle will be created
*/
dragHandleSelector?: string;
/**
* Tags to be excluded for drag handle
*/
excludedTags: string[];
/**
* Custom nodes to be included for drag handle
*/
customNodes: string[];
/**
* onNodeChange callback for drag handle
* @param data
* @returns
*/
onMouseMove?: (data: { node: Node; pos: number }) => void;
}
function absoluteRect(node: Element) {
const data = node.getBoundingClientRect();
const modal = node.closest('[role="dialog"]');
if (modal && window.getComputedStyle(modal).transform !== 'none') {
const modalRect = modal.getBoundingClientRect();
return {
top: data.top - modalRect.top,
left: data.left - modalRect.left,
width: data.width
};
}
return {
top: data.top,
left: data.left,
width: data.width
};
}
function nodeDOMAtCoords(coords: { x: number; y: number }, options: GlobalDragHandleOptions) {
const selectors = [
'li',
'p:not(:first-child)',
'pre',
'blockquote',
'h1',
'h2',
'h3',
'h4',
'h5',
'h6',
...options.customNodes.map((node) => `[data-type=${node}]`)
].join(', ');
return document
.elementsFromPoint(coords.x, coords.y)
.find(
(elem: Element) => elem.parentElement?.matches?.('.ProseMirror') || elem.matches(selectors)
);
}
function nodePosAtDOM(node: Element, view: EditorView, options: GlobalDragHandleOptions) {
const boundingRect = node.getBoundingClientRect();
return view.posAtCoords({
left: boundingRect.left + 50 + options.dragHandleWidth,
top: boundingRect.top + 1
})?.inside;
}
function calcNodePos(pos: number, view: EditorView) {
const $pos = view.state.doc.resolve(pos);
if ($pos.depth > 1) return $pos.before($pos.depth);
return pos;
}
export function DragHandlePlugin(options: GlobalDragHandleOptions & { pluginKey: string }) {
let listType = '';
function handleDragStart(event: DragEvent, view: EditorView) {
view.focus();
if (!event.dataTransfer) return;
const node = nodeDOMAtCoords(
{
x: event.clientX + 50 + options.dragHandleWidth,
y: event.clientY
},
options
);
if (!(node instanceof Element)) return;
let draggedNodePos = nodePosAtDOM(node, view, options);
if (draggedNodePos == null || draggedNodePos < 0) return;
draggedNodePos = calcNodePos(draggedNodePos, view);
const { from, to } = view.state.selection;
const diff = from - to;
const fromSelectionPos = calcNodePos(from, view);
let differentNodeSelected = false;
const nodePos = view.state.doc.resolve(fromSelectionPos);
// Check if nodePos points to the top level node
if (nodePos.node().type.name === 'doc') differentNodeSelected = true;
else {
const nodeSelection = NodeSelection.create(view.state.doc, nodePos.before());
// Check if the node where the drag event started is part of the current selection
differentNodeSelected = !(
draggedNodePos + 1 >= nodeSelection.$from.pos && draggedNodePos <= nodeSelection.$to.pos
);
}
let selection = view.state.selection;
if (!differentNodeSelected && diff !== 0 && !(view.state.selection instanceof NodeSelection)) {
const endSelection = NodeSelection.create(view.state.doc, to - 1);
selection = TextSelection.create(view.state.doc, draggedNodePos, endSelection.$to.pos);
} else {
selection = NodeSelection.create(view.state.doc, draggedNodePos);
// if inline node is selected, e.g mention -> go to the parent node to select the whole node
// if table row is selected, go to the parent node to select the whole node
if (
(selection as NodeSelection).node.type.isInline ||
(selection as NodeSelection).node.type.name === 'tableRow'
) {
const $pos = view.state.doc.resolve(selection.from);
selection = NodeSelection.create(view.state.doc, $pos.before());
}
}
view.dispatch(view.state.tr.setSelection(selection));
// If the selected node is a list item, we need to save the type of the wrapping list e.g. OL or UL
if (
view.state.selection instanceof NodeSelection &&
view.state.selection.node.type.name === 'listItem'
) {
listType = node.parentElement!.tagName;
}
const slice = view.state.selection.content();
const { dom, text } = serializeForClipboard(view, slice);
event.dataTransfer.clearData();
event.dataTransfer.setData('text/html', dom.innerHTML);
event.dataTransfer.setData('text/plain', text);
event.dataTransfer.effectAllowed = 'copyMove';
event.dataTransfer.setDragImage(node, 0, 0);
view.dragging = { slice, move: event.ctrlKey };
}
let dragHandleElement: HTMLElement | null = null;
function hideDragHandle() {
if (dragHandleElement) {
dragHandleElement.classList.add('hide');
}
}
function showDragHandle() {
if (dragHandleElement) {
dragHandleElement.classList.remove('hide');
}
}
function hideHandleOnEditorOut(event: MouseEvent) {
if (event.target instanceof Element) {
// Check if the relatedTarget class is still inside the editor
const relatedTarget = event.relatedTarget as HTMLElement;
const isInsideEditor =
relatedTarget?.classList.contains('tiptap') ||
relatedTarget?.classList.contains('drag-handle');
if (isInsideEditor) return;
}
hideDragHandle();
}
return new Plugin({
key: new PluginKey(options.pluginKey),
view: (view) => {
const handleBySelector = options.dragHandleSelector
? document.querySelector<HTMLElement>(options.dragHandleSelector)
: null;
dragHandleElement = handleBySelector ?? document.createElement('div');
dragHandleElement.draggable = true;
dragHandleElement.dataset.dragHandle = '';
dragHandleElement.classList.add('drag-handle');
function onDragHandleDragStart(e: DragEvent) {
handleDragStart(e, view);
}
dragHandleElement.addEventListener('dragstart', onDragHandleDragStart);
function onDragHandleDrag(e: DragEvent) {
hideDragHandle();
const scrollY = window.scrollY;
if (e.clientY < options.scrollTreshold) {
window.scrollTo({ top: scrollY - 30, behavior: 'smooth' });
} else if (window.innerHeight - e.clientY < options.scrollTreshold) {
window.scrollTo({ top: scrollY + 30, behavior: 'smooth' });
}
}
dragHandleElement.addEventListener('drag', onDragHandleDrag);
hideDragHandle();
if (!handleBySelector) {
view?.dom?.parentElement?.appendChild(dragHandleElement);
}
view?.dom?.parentElement?.addEventListener('mouseout', hideHandleOnEditorOut);
return {
destroy: () => {
if (!handleBySelector) {
dragHandleElement?.remove?.();
}
dragHandleElement?.removeEventListener('drag', onDragHandleDrag);
dragHandleElement?.removeEventListener('dragstart', onDragHandleDragStart);
dragHandleElement = null;
view?.dom?.parentElement?.removeEventListener('mouseout', hideHandleOnEditorOut);
}
};
},
props: {
handleDOMEvents: {
mousemove: (view, event) => {
if (!view.editable) {
return;
}
const node = nodeDOMAtCoords(
{
x: event.clientX + 50 + options.dragHandleWidth,
y: event.clientY
},
options
);
const notDragging = node?.closest('.not-draggable');
const excludedTagList = options.excludedTags.concat(['ol', 'ul']).join(', ');
if (!(node instanceof Element) || node.matches(excludedTagList) || notDragging) {
hideDragHandle();
return;
}
const nodePos = nodePosAtDOM(node, view, options);
if (nodePos !== undefined) {
const currentNode = view.state.doc.nodeAt(nodePos);
if (currentNode !== null) {
options.onMouseMove?.({ node: currentNode, pos: nodePos });
}
}
const compStyle = window.getComputedStyle(node);
const parsedLineHeight = parseInt(compStyle.lineHeight, 10);
const lineHeight = isNaN(parsedLineHeight)
? parseInt(compStyle.fontSize) * 1.2
: parsedLineHeight;
const paddingTop = parseInt(compStyle.paddingTop, 10);
const rect = absoluteRect(node);
rect.top += (lineHeight - 24) / 2;
rect.top += paddingTop;
// Li markers
if (node.matches('ul:not([data-type=taskList]) li, ol li')) {
rect.left -= options.dragHandleWidth;
}
rect.width = options.dragHandleWidth;
if (!dragHandleElement) return;
dragHandleElement.style.left = `${rect.left - rect.width}px`;
dragHandleElement.style.top = `${rect.top}px`;
showDragHandle();
},
keydown: () => {
hideDragHandle();
},
mousewheel: () => {
hideDragHandle();
},
// dragging class is used for CSS
dragstart: (view) => {
view.dom.classList.add('dragging');
},
drop: (view, event) => {
view.dom.classList.remove('dragging');
hideDragHandle();
let droppedNode: Node | null = null;
const dropPos = view.posAtCoords({
left: event.clientX,
top: event.clientY
});
if (!dropPos) return;
if (view.state.selection instanceof NodeSelection) {
droppedNode = view.state.selection.node;
}
if (!droppedNode) return;
const resolvedPos = view.state.doc.resolve(dropPos.pos);
const isDroppedInsideList = resolvedPos.parent.type.name === 'listItem';
// If the selected node is a list item and is not dropped inside a list, we need to wrap it inside <ol> tag otherwise ol list items will be transformed into ul list item when dropped
if (
view.state.selection instanceof NodeSelection &&
view.state.selection.node.type.name === 'listItem' &&
!isDroppedInsideList &&
listType == 'OL'
) {
const newList = view.state.schema.nodes.orderedList?.createAndFill(null, droppedNode);
const slice = new Slice(Fragment.from(newList), 0, 0);
view.dragging = { slice, move: event.ctrlKey };
}
},
dragend: (view) => {
view.dom.classList.remove('dragging');
}
}
}
});
}
const GlobalDragHandle = Extension.create({
name: 'globalDragHandle',
addOptions() {
return {
dragHandleWidth: 20,
scrollTreshold: 100,
excludedTags: [],
customNodes: []
};
},
addProseMirrorPlugins() {
return [
DragHandlePlugin({
pluginKey: 'globalDragHandle',
dragHandleWidth: this.options.dragHandleWidth,
scrollTreshold: this.options.scrollTreshold,
dragHandleSelector: this.options.dragHandleSelector,
excludedTags: this.options.excludedTags,
customNodes: this.options.customNodes,
onMouseMove: this.options.onMouseMove
})
];
}
});
export default GlobalDragHandle;

View file

@ -0,0 +1,85 @@
import { Node } from '@tiptap/core';
export interface IframeOptions {
allowFullscreen: boolean;
HTMLAttributes: {
[key: string]: unknown;
};
}
declare module '@tiptap/core' {
interface Commands<ReturnType> {
iframe: {
/**
* Add an iframe with src
*/
setIframe: (options: { src: string }) => ReturnType;
removeIframe: () => ReturnType;
};
}
}
export default Node.create<IframeOptions>({
name: 'iframe',
group: 'block',
atom: true,
addOptions() {
return {
allowFullscreen: true,
HTMLAttributes: {
class: 'iframe-wrapper'
}
};
},
addAttributes() {
return {
src: {
default: null
},
frameborder: {
default: 0
},
allowfullscreen: {
default: this.options.allowFullscreen,
parseHTML: () => this.options.allowFullscreen
}
};
},
parseHTML() {
return [
{
tag: 'iframe'
}
];
},
renderHTML({ HTMLAttributes }) {
return ['div', this.options.HTMLAttributes, ['iframe', HTMLAttributes]];
},
addCommands() {
return {
setIframe:
(options: { src: string }) =>
({ tr, dispatch }) => {
const { selection } = tr;
const node = this.type.create(options);
if (dispatch) {
tr.replaceRangeWith(selection.from, selection.to, node);
}
return true;
},
removeIframe:
() =>
({ commands }) =>
commands.deleteNode(this.name)
};
}
});

View file

@ -0,0 +1,35 @@
import { SvelteNodeViewRenderer } from 'svelte-tiptap';
import type { NodeViewProps } from '@tiptap/core';
import type { Component } from 'svelte';
import IFrame from './IFrame.js';
export const IFrameExtended = (content: Component<NodeViewProps>) =>
IFrame.extend({
addAttributes() {
return {
src: {
default: null
},
alt: {
default: null
},
title: {
default: null
},
width: {
default: '100%'
},
height: {
default: null
},
align: {
default: 'left'
}
};
},
addNodeView: () => {
return SvelteNodeViewRenderer(content);
}
});

View file

@ -0,0 +1,56 @@
import { Node, mergeAttributes, type CommandProps, type NodeViewProps } from '@tiptap/core';
import type { Component } from 'svelte';
import { SvelteNodeViewRenderer } from 'svelte-tiptap';
export interface IFramePlaceholderOptions {
HTMLAttributes: Record<string, object>;
}
declare module '@tiptap/core' {
interface Commands<ReturnType> {
iframePlaceholder: {
/**
* Inserts a IFrame placeholder
*/
insertIFramePlaceholder: () => ReturnType;
};
}
}
export const IFramePlaceholder = (content: Component<NodeViewProps>) =>
Node.create<IFramePlaceholderOptions>({
name: 'iframe-placeholder',
addOptions() {
return {
HTMLAttributes: {},
onDrop: () => {},
onDropRejected: () => {},
onEmbed: () => {}
};
},
parseHTML() {
return [{ tag: `div[data-type="${this.name}"]` }];
},
renderHTML({ HTMLAttributes }) {
return ['div', mergeAttributes(HTMLAttributes)];
},
group: 'block',
draggable: true,
atom: true,
content: 'inline*',
isolating: true,
addNodeView() {
return SvelteNodeViewRenderer(content);
},
addCommands() {
return {
insertIFramePlaceholder: () => (props: CommandProps) => {
return props.commands.insertContent({
type: 'iframe-placeholder'
});
}
};
}
});

View file

@ -0,0 +1,36 @@
import { SvelteNodeViewRenderer } from 'svelte-tiptap';
import Image, { type ImageOptions } from '@tiptap/extension-image';
import type { Component } from 'svelte';
import type { NodeViewProps, Node } from '@tiptap/core';
export const ImageExtended = (component: Component<NodeViewProps>): Node<ImageOptions, unknown> => {
return Image.extend({
addAttributes() {
return {
src: {
default: null
},
alt: {
default: null
},
title: {
default: null
},
width: {
default: '100%'
},
height: {
default: null
},
align: {
default: 'left'
}
};
},
addNodeView: () => {
return SvelteNodeViewRenderer(component);
}
}).configure({
allowBase64: true
});
};

View file

@ -0,0 +1,64 @@
import { Editor, Node, mergeAttributes, type CommandProps, type NodeViewProps } from '@tiptap/core';
import type { Component } from 'svelte';
import { SvelteNodeViewRenderer } from 'svelte-tiptap';
export interface ImagePlaceholderOptions {
HTMLAttributes: Record<string, object>;
onDrop: (files: File[], editor: Editor) => void;
onDropRejected?: (files: File[], editor: Editor) => void;
onEmbed: (url: string, editor: Editor) => void;
allowedMimeTypes?: Record<string, string[]>;
maxFiles?: number;
maxSize?: number;
}
declare module '@tiptap/core' {
interface Commands<ReturnType> {
imagePlaceholder: {
/**
* Inserts an image placeholder
*/
insertImagePlaceholder: () => ReturnType;
};
}
}
export const ImagePlaceholder = (
component: Component<NodeViewProps>
): Node<ImagePlaceholderOptions> =>
Node.create<ImagePlaceholderOptions>({
name: 'image-placeholder',
addOptions() {
return {
HTMLAttributes: {},
onDrop: () => {},
onDropRejected: () => {},
onEmbed: () => {}
};
},
parseHTML() {
return [{ tag: `div[data-type="${this.name}"]` }];
},
renderHTML({ HTMLAttributes }) {
return ['div', mergeAttributes(HTMLAttributes)];
},
group: 'block',
draggable: true,
atom: true,
content: 'inline*',
isolating: true,
addNodeView() {
return SvelteNodeViewRenderer(component);
},
addCommands() {
return {
insertImagePlaceholder: () => (props: CommandProps) => {
return props.commands.insertContent({
type: 'image-placeholder'
});
}
};
}
});

View file

@ -0,0 +1,59 @@
import commands from '../../commands/toolbar-commands.js';
import type { EdraToolBarCommands } from '../../commands/types.js';
import type { Editor } from '@tiptap/core';
import Quote from '@lucide/svelte/icons/quote';
import SquareCode from '@lucide/svelte/icons/square-code';
import Minus from '@lucide/svelte/icons/minus';
export interface Group {
name: string;
title: string;
actions: EdraToolBarCommands[];
}
export const GROUPS: Group[] = [
{
name: 'format',
title: 'Format',
actions: [
...commands.headings,
{
icon: Quote,
name: 'blockquote',
tooltip: 'Blockquote',
onClick: (editor: Editor) => {
editor.chain().focus().setBlockquote().run();
}
},
{
icon: SquareCode,
name: 'codeBlock',
tooltip: 'Code Block',
onClick: (editor: Editor) => {
editor.chain().focus().setCodeBlock().run();
}
},
...commands.lists
]
},
{
name: 'insert',
title: 'Insert',
actions: [
...commands.media,
...commands.table,
...commands.math,
{
icon: Minus,
name: 'horizontalRule',
tooltip: 'Horizontal Rule',
onClick: (editor: Editor) => {
editor.chain().focus().setHorizontalRule().run();
}
}
]
}
];
export default GROUPS;

View file

@ -0,0 +1,259 @@
import { Editor, Extension } from '@tiptap/core';
import Suggestion, { type SuggestionProps, type SuggestionKeyDownProps } from '@tiptap/suggestion';
import { PluginKey } from '@tiptap/pm/state';
import { computePosition, flip, offset, autoUpdate, type Placement } from '@floating-ui/dom';
import { GROUPS } from './groups.js';
import SvelteRenderer from '../../svelte-renderer.js';
import type { Component } from 'svelte';
const extensionName = 'slashCommand';
interface PopupState {
element: HTMLElement | null;
cleanup: (() => void) | null;
isVisible: boolean;
}
const popup: PopupState = {
element: null,
cleanup: null,
isVisible: false
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export default (menuList: Component<any, any, ''>): Extension =>
Extension.create({
name: extensionName,
priority: 200,
onCreate() {
// Create popup container
popup.element = document.createElement('div');
popup.element.style.position = 'fixed';
popup.element.style.zIndex = '9999';
popup.element.style.maxWidth = '16rem';
popup.element.style.visibility = 'hidden';
popup.element.style.pointerEvents = 'none';
popup.element.className = 'slash-command-popup';
document.body.appendChild(popup.element);
},
addProseMirrorPlugins() {
return [
Suggestion({
editor: this.editor,
char: '/',
allowSpaces: true,
pluginKey: new PluginKey(extensionName),
allow: ({ state, range }) => {
const $from = state.doc.resolve(range.from);
const afterContent = $from.parent.textContent?.substring(
$from.parent.textContent?.indexOf('/')
);
const isValidAfterContent = !afterContent?.endsWith(' ');
return isValidAfterContent;
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
command: ({ editor, props }: { editor: Editor; props: any }) => {
const { view, state } = editor;
const { $head, $from } = view.state.selection;
try {
const end = $from.pos;
const from = $head?.nodeBefore
? end -
($head.nodeBefore.text?.substring($head.nodeBefore.text?.indexOf('/')).length ??
0)
: $from.start();
const tr = state.tr.deleteRange(from, end);
view.dispatch(tr);
} catch (error) {
console.error(error);
}
props.onClick(editor);
view.focus();
},
items: ({ query }: { query: string }) => {
const withFilteredCommands = GROUPS.map((group) => ({
...group,
commands: group.actions.filter((item) => {
const labelNormalized = item.tooltip!.toLowerCase().trim();
const queryNormalized = query.toLowerCase().trim();
return labelNormalized.includes(queryNormalized);
})
}));
const withoutEmptyGroups = withFilteredCommands.filter((group) => {
if (group.commands.length > 0) {
return true;
}
return false;
});
const withEnabledSettings = withoutEmptyGroups.map((group) => ({
...group,
commands: group.commands.map((command) => ({
...command,
isEnabled: true
}))
}));
return withEnabledSettings;
},
render: () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let component: any;
let scrollHandler: (() => void) | null = null;
return {
onStart: (props: SuggestionProps) => {
component = new SvelteRenderer(menuList, {
props,
editor: props.editor
});
const { view } = props.editor;
if (popup.element) {
popup.element.appendChild(component.element);
popup.element.style.visibility = 'visible';
popup.element.style.pointerEvents = 'auto';
popup.isVisible = true;
const updatePosition = () => {
if (!popup.element || !props.clientRect) return;
const rect = props.clientRect();
if (!rect) return;
const referenceElement = {
getBoundingClientRect: () => rect
};
computePosition(referenceElement, popup.element, {
placement: 'bottom-start' as Placement,
middleware: [
offset({ mainAxis: 8, crossAxis: 16 }),
flip({ fallbackPlacements: ['top-start', 'bottom-start'] })
]
}).then(({ x, y }) => {
if (popup.element) {
popup.element.style.left = `${x}px`;
popup.element.style.top = `${y}px`;
}
});
};
updatePosition();
// Set up auto-update for scroll events
if (props.clientRect) {
const referenceElement = {
getBoundingClientRect: () => props.clientRect?.() || new DOMRect()
};
popup.cleanup = autoUpdate(referenceElement, popup.element, updatePosition);
}
scrollHandler = updatePosition;
view.dom.parentElement?.addEventListener('scroll', scrollHandler);
}
},
onUpdate(props: SuggestionProps) {
component.updateProps(props);
if (popup.element && popup.isVisible && props.clientRect) {
const rect = props.clientRect();
if (rect) {
const referenceElement = {
getBoundingClientRect: () => rect
};
computePosition(referenceElement, popup.element, {
placement: 'bottom-start' as Placement,
middleware: [
offset({ mainAxis: 8, crossAxis: 16 }),
flip({ fallbackPlacements: ['top-start', 'bottom-start'] })
]
}).then(({ x, y }) => {
if (popup.element) {
popup.element.style.left = `${x}px`;
popup.element.style.top = `${y}px`;
}
});
props.editor.storage[extensionName].rect = rect;
}
}
},
onKeyDown(props: SuggestionKeyDownProps) {
if (props.event.key === 'Escape') {
if (popup.element) {
popup.element.style.visibility = 'hidden';
popup.element.style.pointerEvents = 'none';
popup.isVisible = false;
}
return true;
}
if (!popup.isVisible && popup.element) {
popup.element.style.visibility = 'visible';
popup.element.style.pointerEvents = 'auto';
popup.isVisible = true;
}
if (props.event.key === 'Enter') return true;
// return component.ref?.onKeyDown(props);
return false;
},
onExit(props) {
if (popup.element) {
popup.element.style.visibility = 'hidden';
popup.element.style.pointerEvents = 'none';
popup.element.innerHTML = '';
popup.isVisible = false;
}
if (popup.cleanup) {
popup.cleanup();
popup.cleanup = null;
}
if (scrollHandler) {
const { view } = props.editor;
view.dom.parentElement?.removeEventListener('scroll', scrollHandler);
scrollHandler = null;
}
component.destroy();
}
};
}
})
];
},
addStorage() {
return {
rect: {
width: 0,
height: 0,
left: 0,
top: 0,
right: 0,
bottom: 0
}
};
}
});

View file

@ -0,0 +1,4 @@
export { Table } from './table.js';
export { TableCell } from './table-cell.js';
export { TableRow } from './table-row.js';
export { TableHeader } from './table-header.js';

View file

@ -0,0 +1,124 @@
import { mergeAttributes, Node } from '@tiptap/core';
import { Plugin } from '@tiptap/pm/state';
import { Decoration, DecorationSet } from '@tiptap/pm/view';
import { getCellsInColumn, isRowSelected, selectRow } from './utils.js';
export interface TableCellOptions {
HTMLAttributes: Record<string, unknown>;
}
export const TableCell = Node.create<TableCellOptions>({
name: 'tableCell',
content: 'block+',
tableRole: 'cell',
isolating: true,
addOptions() {
return {
HTMLAttributes: {}
};
},
parseHTML() {
return [{ tag: 'td' }];
},
renderHTML({ HTMLAttributes }) {
return ['td', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0];
},
addAttributes() {
return {
colspan: {
default: 1,
parseHTML: (element) => {
const colspan = element.getAttribute('colspan');
const value = colspan ? parseInt(colspan, 10) : 1;
return value;
}
},
rowspan: {
default: 1,
parseHTML: (element) => {
const rowspan = element.getAttribute('rowspan');
const value = rowspan ? parseInt(rowspan, 10) : 1;
return value;
}
},
colwidth: {
default: null,
parseHTML: (element) => {
const colwidth = element.getAttribute('colwidth');
const value = colwidth ? [parseInt(colwidth, 10)] : null;
return value;
}
},
style: {
default: null
}
};
},
addProseMirrorPlugins() {
const { isEditable } = this.editor;
return [
new Plugin({
props: {
decorations: (state) => {
if (!isEditable) {
return DecorationSet.empty;
}
const { doc, selection } = state;
const decorations: Decoration[] = [];
const cells = getCellsInColumn(0)(selection);
if (cells) {
cells.forEach(({ pos }: { pos: number }, index: number) => {
decorations.push(
Decoration.widget(pos + 1, () => {
const rowSelected = isRowSelected(index)(selection);
let className = 'grip-row';
if (rowSelected) {
className += ' selected';
}
if (index === 0) {
className += ' first';
}
if (index === cells.length - 1) {
className += ' last';
}
const grip = document.createElement('a');
grip.className = className;
grip.addEventListener('mousedown', (event) => {
event.preventDefault();
event.stopImmediatePropagation();
this.editor.view.dispatch(selectRow(index)(this.editor.state.tr));
});
return grip;
})
);
});
}
return DecorationSet.create(doc, decorations);
}
}
})
];
}
});

View file

@ -0,0 +1,89 @@
import { TableHeader as TiptapTableHeader } from '@tiptap/extension-table';
import { Plugin } from '@tiptap/pm/state';
import { Decoration, DecorationSet } from '@tiptap/pm/view';
import { getCellsInRow, isColumnSelected, selectColumn } from './utils.js';
export const TableHeader = TiptapTableHeader.extend({
addAttributes() {
return {
colspan: {
default: 1
},
rowspan: {
default: 1
},
colwidth: {
default: null,
parseHTML: (element) => {
const colwidth = element.getAttribute('colwidth');
const value = colwidth ? colwidth.split(',').map((item) => parseInt(item, 10)) : null;
return value;
}
},
style: {
default: null
}
};
},
addProseMirrorPlugins() {
const { isEditable } = this.editor;
return [
new Plugin({
props: {
decorations: (state) => {
if (!isEditable) {
return DecorationSet.empty;
}
const { doc, selection } = state;
const decorations: Decoration[] = [];
const cells = getCellsInRow(0)(selection);
if (cells) {
cells.forEach(({ pos }: { pos: number }, index: number) => {
decorations.push(
Decoration.widget(pos + 1, () => {
const colSelected = isColumnSelected(index)(selection);
let className = 'grip-column';
if (colSelected) {
className += ' selected';
}
if (index === 0) {
className += ' first';
}
if (index === cells.length - 1) {
className += ' last';
}
const grip = document.createElement('a');
grip.className = className;
grip.addEventListener('mousedown', (event) => {
event.preventDefault();
event.stopImmediatePropagation();
this.editor.view.dispatch(selectColumn(index)(this.editor.state.tr));
});
return grip;
})
);
});
}
return DecorationSet.create(doc, decorations);
}
}
})
];
}
});
export default TableHeader;

View file

@ -0,0 +1,8 @@
import { TableRow as TiptapTableRow } from '@tiptap/extension-table';
export const TableRow = TiptapTableRow.extend({
allowGapCursor: false,
content: '(tableCell | tableHeader)*'
});
export default TableRow;

View file

@ -0,0 +1,9 @@
import { Table as TiptapTable } from '@tiptap/extension-table';
export const Table = TiptapTable.configure({
resizable: true,
lastColumnResizable: true,
allowTableNodeSelection: true
});
export default Table;

View file

@ -0,0 +1,322 @@
import { Editor, findParentNode } from '@tiptap/core';
import { EditorState, Selection, Transaction } from '@tiptap/pm/state';
import { CellSelection, type Rect, TableMap } from '@tiptap/pm/tables';
import { Node, ResolvedPos } from '@tiptap/pm/model';
import type { EditorView } from '@tiptap/pm/view';
import Table from './table.js';
export const isRectSelected = (rect: Rect) => (selection: CellSelection) => {
const map = TableMap.get(selection.$anchorCell.node(-1));
const start = selection.$anchorCell.start(-1);
const cells = map.cellsInRect(rect);
const selectedCells = map.cellsInRect(
map.rectBetween(selection.$anchorCell.pos - start, selection.$headCell.pos - start)
);
for (let i = 0, count = cells.length; i < count; i += 1) {
if (selectedCells.indexOf(cells[i]) === -1) {
return false;
}
}
return true;
};
export const findTable = (selection: Selection) =>
findParentNode((node) => node.type.spec.tableRole && node.type.spec.tableRole === 'table')(
selection
);
export const isCellSelection = (selection: Selection): selection is CellSelection =>
selection instanceof CellSelection;
export const isColumnSelected = (columnIndex: number) => (selection: Selection) => {
if (isCellSelection(selection)) {
const map = TableMap.get(selection.$anchorCell.node(-1));
return isRectSelected({
left: columnIndex,
right: columnIndex + 1,
top: 0,
bottom: map.height
})(selection);
}
return false;
};
export const isRowSelected = (rowIndex: number) => (selection: Selection) => {
if (isCellSelection(selection)) {
const map = TableMap.get(selection.$anchorCell.node(-1));
return isRectSelected({
left: 0,
right: map.width,
top: rowIndex,
bottom: rowIndex + 1
})(selection);
}
return false;
};
export const isTableSelected = (selection: Selection) => {
if (isCellSelection(selection)) {
const map = TableMap.get(selection.$anchorCell.node(-1));
return isRectSelected({
left: 0,
right: map.width,
top: 0,
bottom: map.height
})(selection);
}
return false;
};
export const getCellsInColumn = (columnIndex: number | number[]) => (selection: Selection) => {
const table = findTable(selection);
if (table) {
const map = TableMap.get(table.node);
const indexes = Array.isArray(columnIndex) ? columnIndex : Array.from([columnIndex]);
return indexes.reduce(
(acc, index) => {
if (index >= 0 && index <= map.width - 1) {
const cells = map.cellsInRect({
left: index,
right: index + 1,
top: 0,
bottom: map.height
});
return acc.concat(
cells.map((nodePos) => {
const node = table.node.nodeAt(nodePos);
const pos = nodePos + table.start;
return { pos, start: pos + 1, node };
})
);
}
return acc;
},
[] as { pos: number; start: number; node: Node | null | undefined }[]
);
}
return null;
};
export const getCellsInRow = (rowIndex: number | number[]) => (selection: Selection) => {
const table = findTable(selection);
if (table) {
const map = TableMap.get(table.node);
const indexes = Array.isArray(rowIndex) ? rowIndex : Array.from([rowIndex]);
return indexes.reduce(
(acc, index) => {
if (index >= 0 && index <= map.height - 1) {
const cells = map.cellsInRect({
left: 0,
right: map.width,
top: index,
bottom: index + 1
});
return acc.concat(
cells.map((nodePos) => {
const node = table.node.nodeAt(nodePos);
const pos = nodePos + table.start;
return { pos, start: pos + 1, node };
})
);
}
return acc;
},
[] as { pos: number; start: number; node: Node | null | undefined }[]
);
}
return null;
};
export const getCellsInTable = (selection: Selection) => {
const table = findTable(selection);
if (table) {
const map = TableMap.get(table.node);
const cells = map.cellsInRect({
left: 0,
right: map.width,
top: 0,
bottom: map.height
});
return cells.map((nodePos) => {
const node = table.node.nodeAt(nodePos);
const pos = nodePos + table.start;
return { pos, start: pos + 1, node };
});
}
return null;
};
export const findParentNodeClosestToPos = (
$pos: ResolvedPos,
predicate: (node: Node) => boolean
) => {
for (let i = $pos.depth; i > 0; i -= 1) {
const node = $pos.node(i);
if (predicate(node)) {
return {
pos: i > 0 ? $pos.before(i) : 0,
start: $pos.start(i),
depth: i,
node
};
}
}
return null;
};
export const findCellClosestToPos = ($pos: ResolvedPos) => {
const predicate = (node: Node) =>
node.type.spec.tableRole && /cell/i.test(node.type.spec.tableRole);
return findParentNodeClosestToPos($pos, predicate);
};
const select = (type: 'row' | 'column') => (index: number) => (tr: Transaction) => {
const table = findTable(tr.selection);
const isRowSelection = type === 'row';
if (table) {
const map = TableMap.get(table.node);
// Check if the index is valid
if (index >= 0 && index < (isRowSelection ? map.height : map.width)) {
const left = isRowSelection ? 0 : index;
const top = isRowSelection ? index : 0;
const right = isRowSelection ? map.width : index + 1;
const bottom = isRowSelection ? index + 1 : map.height;
const cellsInFirstRow = map.cellsInRect({
left,
top,
right: isRowSelection ? right : left + 1,
bottom: isRowSelection ? top + 1 : bottom
});
const cellsInLastRow =
bottom - top === 1
? cellsInFirstRow
: map.cellsInRect({
left: isRowSelection ? left : right - 1,
top: isRowSelection ? bottom - 1 : top,
right,
bottom
});
const head = table.start + cellsInFirstRow[0];
const anchor = table.start + cellsInLastRow[cellsInLastRow.length - 1];
const $head = tr.doc.resolve(head);
const $anchor = tr.doc.resolve(anchor);
return tr.setSelection(new CellSelection($anchor, $head));
}
}
return tr;
};
export const selectColumn = select('column');
export const selectRow = select('row');
export const selectTable = (tr: Transaction) => {
const table = findTable(tr.selection);
if (table) {
const { map } = TableMap.get(table.node);
if (map && map.length) {
const head = table.start + map[0];
const anchor = table.start + map[map.length - 1];
const $head = tr.doc.resolve(head);
const $anchor = tr.doc.resolve(anchor);
return tr.setSelection(new CellSelection($anchor, $head));
}
}
return tr;
};
export const isColumnGripSelected = ({
editor,
view,
state,
from
}: {
editor: Editor;
view: EditorView;
state: EditorState;
from: number;
}) => {
const domAtPos = view.domAtPos(from).node as HTMLElement;
const nodeDOM = view.nodeDOM(from) as HTMLElement;
const node = nodeDOM || domAtPos;
if (!editor.isActive(Table.name) || !node || isTableSelected(state.selection)) {
return false;
}
let container = node;
while (container && !['TD', 'TH'].includes(container.tagName)) {
container = container.parentElement!;
}
const gripColumn =
container && container.querySelector && container.querySelector('a.grip-column.selected');
return !!gripColumn;
};
export const isRowGripSelected = ({
editor,
view,
state,
from
}: {
editor: Editor;
view: EditorView;
state: EditorState;
from: number;
}) => {
const domAtPos = view.domAtPos(from).node as HTMLElement;
const nodeDOM = view.nodeDOM(from) as HTMLElement;
const node = nodeDOM || domAtPos;
if (!editor.isActive(Table.name) || !node || isTableSelected(state.selection)) {
return false;
}
let container = node;
while (container && !['TD', 'TH'].includes(container.tagName)) {
container = container.parentElement!;
}
const gripRow =
container && container.querySelector && container.querySelector('a.grip-row.selected');
return !!gripRow;
};

View file

@ -0,0 +1,34 @@
import { SvelteNodeViewRenderer } from 'svelte-tiptap';
import { Video } from './VideoExtension.js';
import type { NodeViewProps } from '@tiptap/core';
import type { Component } from 'svelte';
export const VideoExtended = (content: Component<NodeViewProps>) =>
Video.extend({
addAttributes() {
return {
src: {
default: null
},
alt: {
default: null
},
title: {
default: null
},
width: {
default: '100%'
},
height: {
default: null
},
align: {
default: 'left'
}
};
},
addNodeView: () => {
return SvelteNodeViewRenderer(content);
}
});

View file

@ -0,0 +1,147 @@
import { Node, nodeInputRule } from '@tiptap/core';
import { Plugin, PluginKey } from '@tiptap/pm/state';
export interface VideoOptions {
HTMLAttributes: Record<string, unknown>;
}
declare module '@tiptap/core' {
interface Commands<ReturnType> {
video: {
/**
* Set a video node
*/
setVideo: (src: string) => ReturnType;
/**
* Toggle a video
*/
toggleVideo: (src: string) => ReturnType;
/**
* Remove a video
*/
removeVideo: () => ReturnType;
};
}
}
const VIDEO_INPUT_REGEX = /!\[(.+|:?)]\((\S+)(?:(?:\s+)["'](\S+)["'])?\)/;
export const Video = Node.create<VideoOptions>({
name: 'video',
group: 'block',
content: 'inline*',
draggable: true,
isolating: true,
addOptions() {
return {
HTMLAttributes: {}
};
},
addAttributes() {
return {
src: {
default: null,
parseHTML: (el) => (el as HTMLSpanElement).getAttribute('src'),
renderHTML: (attrs) => ({ src: attrs.src })
}
};
},
parseHTML() {
return [
{
tag: 'video',
getAttrs: (el) => ({ src: (el as HTMLVideoElement).getAttribute('src') })
}
];
},
renderHTML({ HTMLAttributes }) {
return [
'video',
{ controls: 'true', style: 'width: fit-content;', ...HTMLAttributes },
['source', HTMLAttributes]
];
},
addCommands() {
return {
setVideo:
(src: string) =>
({ commands }) =>
commands.insertContent(
`<video controls="true" autoplay="false" style="width: fit-content" src="${src}" />`
),
toggleVideo:
() =>
({ commands }) =>
commands.toggleNode(this.name, 'paragraph'),
removeVideo:
() =>
({ commands }) =>
commands.deleteNode(this.name)
};
},
addInputRules() {
return [
nodeInputRule({
find: VIDEO_INPUT_REGEX,
type: this.type,
getAttributes: (match) => {
const [, , src] = match;
return { src };
}
})
];
},
addProseMirrorPlugins() {
return [
new Plugin({
key: new PluginKey('videoDropPlugin'),
props: {
handleDOMEvents: {
drop(view, event) {
const {
state: { schema, tr },
dispatch
} = view;
const hasFiles =
event.dataTransfer && event.dataTransfer.files && event.dataTransfer.files.length;
if (!hasFiles) return false;
const videos = Array.from(event.dataTransfer.files).filter((file) =>
/video/i.test(file.type)
);
if (videos.length === 0) return false;
event.preventDefault();
const coordinates = view.posAtCoords({ left: event.clientX, top: event.clientY });
videos.forEach((video) => {
const reader = new FileReader();
reader.onload = (readerEvent) => {
const node = schema.nodes.video.create({ src: readerEvent.target?.result });
if (coordinates && typeof coordinates.pos === 'number') {
const transaction = tr.insert(coordinates?.pos, node);
dispatch(transaction);
}
};
reader.readAsDataURL(video);
});
return true;
}
}
}
})
];
}
});

View file

@ -0,0 +1,62 @@
import { Editor, Node, mergeAttributes, type CommandProps, type NodeViewProps } from '@tiptap/core';
import type { Component } from 'svelte';
import { SvelteNodeViewRenderer } from 'svelte-tiptap';
export interface VideoPlaceholderOptions {
HTMLAttributes: Record<string, object>;
onDrop: (files: File[], editor: Editor) => void;
onDropRejected?: (files: File[], editor: Editor) => void;
onEmbed: (url: string, editor: Editor) => void;
allowedMimeTypes?: Record<string, string[]>;
maxFiles?: number;
maxSize?: number;
}
declare module '@tiptap/core' {
interface Commands<ReturnType> {
videoPlaceholder: {
/**
* Inserts a video placeholder
*/
insertVideoPlaceholder: () => ReturnType;
};
}
}
export const VideoPlaceholder = (content: Component<NodeViewProps>) =>
Node.create<VideoPlaceholderOptions>({
name: 'video-placeholder',
addOptions() {
return {
HTMLAttributes: {},
onDrop: () => {},
onDropRejected: () => {},
onEmbed: () => {}
};
},
parseHTML() {
return [{ tag: `div[data-type="${this.name}"]` }];
},
renderHTML({ HTMLAttributes }) {
return ['div', mergeAttributes(HTMLAttributes)];
},
group: 'block',
draggable: true,
atom: true,
content: 'inline*',
isolating: true,
addNodeView() {
return SvelteNodeViewRenderer(content);
},
addCommands() {
return {
insertVideoPlaceholder: () => (props: CommandProps) => {
return props.commands.insertContent({
type: 'video-placeholder'
});
}
};
}
});

View file

@ -0,0 +1,13 @@
<script lang="ts">
import type { NodeViewProps } from '@tiptap/core';
import MediaExtended from './MediaExtended.svelte';
const { ...rest }: NodeViewProps = $props();
let mediaRef = $state<HTMLElement>();
</script>
<MediaExtended bind:mediaRef {...rest}>
{@const node = rest.node}
<audio bind:this={mediaRef} src={node.attrs.src} controls title={node.attrs.title}> </audio>
</MediaExtended>

View file

@ -0,0 +1,21 @@
<script lang="ts">
import MediaPlaceHolder from '../../components/MediaPlaceHolder.svelte';
import type { NodeViewProps } from '@tiptap/core';
const { editor }: NodeViewProps = $props();
import Audio from '@lucide/svelte/icons/audio-lines';
function handleClick() {
const audioUrl = prompt('Please enter the audio URL');
if (audioUrl) {
editor.chain().focus().setAudio(audioUrl).run();
}
}
</script>
<MediaPlaceHolder
class="edra-media-placeholder-wrapper"
icon={Audio}
title="Insert an audio"
onClick={handleClick}
/>

View file

@ -0,0 +1,47 @@
<script lang="ts">
import { NodeViewWrapper, NodeViewContent } from 'svelte-tiptap';
import type { NodeViewProps } from '@tiptap/core';
const { node, updateAttributes, extension }: NodeViewProps = $props();
let preRef = $state<HTMLPreElement>();
let isCopying = $state(false);
const languages: string[] = extension.options.lowlight.listLanguages().sort();
let defaultLanguage = $state(node.attrs.language);
$effect(() => {
updateAttributes({ language: defaultLanguage });
});
function copyCode() {
if (isCopying) return;
if (!preRef) return;
isCopying = true;
navigator.clipboard.writeText(preRef.innerText);
setTimeout(() => {
isCopying = false;
}, 1000);
}
</script>
<NodeViewWrapper class="code-wrapper">
<div class="code-wrapper-tile" contenteditable="false">
<select bind:value={defaultLanguage} class="code-wrapper-select browser-default">
{#each languages as language (language)}
<option value={language}>{language}</option>
{/each}
</select>
<button class="code-wrapper-copy" onclick={copyCode}>
{#if isCopying}
<span class="code-wrapper-copy-text copied">Copied!</span>
{:else}
<span class="code-wrapper-copy-text">Copy</span>
{/if}
</button>
</div>
<pre bind:this={preRef} spellcheck="false">
<NodeViewContent as="code" class={`language-${defaultLanguage}`} {...node.attrs} />
</pre>
</NodeViewWrapper>

View file

@ -0,0 +1,13 @@
<script lang="ts">
import type { NodeViewProps } from '@tiptap/core';
import MediaExtended from './MediaExtended.svelte';
const { ...rest }: NodeViewProps = $props();
let mediaRef = $state<HTMLElement>();
</script>
<MediaExtended bind:mediaRef {...rest}>
{@const node = rest.node}
<iframe bind:this={mediaRef} {...node.attrs}> </iframe>
</MediaExtended>

View file

@ -0,0 +1,21 @@
<script lang="ts">
import MediaPlaceHolder from '../../components/MediaPlaceHolder.svelte';
import type { NodeViewProps } from '@tiptap/core';
const { editor }: NodeViewProps = $props();
import Audio from '@lucide/svelte/icons/code-xml';
function handleClick() {
const iframUrl = prompt('Please enter the IFrame URL');
if (iframUrl) {
editor.chain().focus().setIframe({ src: iframUrl }).run();
}
}
</script>
<MediaPlaceHolder
class="edra-media-placeholder-wrapper"
icon={Audio}
title="Insert an iframe"
onClick={handleClick}
/>

View file

@ -0,0 +1,13 @@
<script lang="ts">
import type { NodeViewProps } from '@tiptap/core';
import MediaExtended from './MediaExtended.svelte';
const { ...rest }: NodeViewProps = $props();
let mediaRef = $state<HTMLElement>();
</script>
<MediaExtended bind:mediaRef {...rest}>
{@const node = rest.node}
<img bind:this={mediaRef} src={node.attrs.src} alt={node.attrs.alt} title={node.attrs.title} />
</MediaExtended>

View file

@ -0,0 +1,21 @@
<script lang="ts">
import MediaPlaceHolder from '../../components/MediaPlaceHolder.svelte';
import type { NodeViewProps } from '@tiptap/core';
const { editor }: NodeViewProps = $props();
import Image from '@lucide/svelte/icons/image';
function handleClick() {
const imageUrl = prompt('Please enter the image URL');
if (imageUrl) {
editor.chain().focus().setImage({ src: imageUrl }).run();
}
}
</script>
<MediaPlaceHolder
class="edra-media-placeholder-wrapper"
icon={Image}
title="Insert an image"
onClick={handleClick}
/>

View file

@ -0,0 +1,243 @@
<script lang="ts">
import { onDestroy, onMount, type Snippet } from 'svelte';
import { NodeViewWrapper } from 'svelte-tiptap';
import type { NodeViewProps } from '@tiptap/core';
import AlignCenter from '@lucide/svelte/icons/align-center';
import AlignLeft from '@lucide/svelte/icons/align-left';
import AlignRight from '@lucide/svelte/icons/align-right';
import CopyIcon from '@lucide/svelte/icons/copy';
import Fullscreen from '@lucide/svelte/icons/fullscreen';
import Trash from '@lucide/svelte/icons/trash';
import Captions from '@lucide/svelte/icons/captions';
import { duplicateContent } from '../../utils.js';
interface MediaExtendedProps extends NodeViewProps {
children: Snippet<[]>;
mediaRef?: HTMLElement;
}
const {
node,
editor,
selected,
deleteNode,
updateAttributes,
children,
mediaRef = $bindable()
}: MediaExtendedProps = $props();
const minWidthPercent = 15;
const maxWidthPercent = 100;
let nodeRef = $state<HTMLElement>();
let resizing = $state(false);
let resizingInitialWidthPercent = $state(0);
let resizingInitialMouseX = $state(0);
let resizingPosition = $state<'left' | 'right'>('left');
let caption: string | null = $state(node.attrs.title);
$effect(() => {
if (caption?.trim() === '') caption = null;
updateAttributes({ title: caption });
});
function handleResizingPosition(e: MouseEvent, position: 'left' | 'right') {
startResize(e);
resizingPosition = position;
}
function startResize(e: MouseEvent) {
e.preventDefault();
resizing = true;
resizingInitialMouseX = e.clientX;
if (mediaRef && nodeRef?.parentElement) {
const currentWidth = mediaRef.offsetWidth;
const parentWidth = nodeRef.parentElement.offsetWidth;
resizingInitialWidthPercent = (currentWidth / parentWidth) * 100;
}
}
function resize(e: MouseEvent) {
if (!resizing || !nodeRef?.parentElement) return;
let dx = e.clientX - resizingInitialMouseX;
if (resizingPosition === 'left') {
dx = resizingInitialMouseX - e.clientX;
}
const parentWidth = nodeRef.parentElement.offsetWidth;
const deltaPercent = (dx / parentWidth) * 100;
const newWidthPercent = Math.max(
Math.min(resizingInitialWidthPercent + deltaPercent, maxWidthPercent),
minWidthPercent
);
updateAttributes({ width: `${newWidthPercent}%` });
}
function endResize() {
resizing = false;
resizingInitialMouseX = 0;
resizingInitialWidthPercent = 0;
}
function handleTouchStart(e: TouchEvent, position: 'left' | 'right') {
e.preventDefault();
resizing = true;
resizingPosition = position;
resizingInitialMouseX = e.touches[0].clientX;
if (mediaRef && nodeRef?.parentElement) {
const currentWidth = mediaRef.offsetWidth;
const parentWidth = nodeRef.parentElement.offsetWidth;
resizingInitialWidthPercent = (currentWidth / parentWidth) * 100;
}
}
function handleTouchMove(e: TouchEvent) {
if (!resizing || !nodeRef?.parentElement) return;
let dx = e.touches[0].clientX - resizingInitialMouseX;
if (resizingPosition === 'left') {
dx = resizingInitialMouseX - e.touches[0].clientX;
}
const parentWidth = nodeRef.parentElement.offsetWidth;
const deltaPercent = (dx / parentWidth) * 100;
const newWidthPercent = Math.max(
Math.min(resizingInitialWidthPercent + deltaPercent, maxWidthPercent),
minWidthPercent
);
updateAttributes({ width: `${newWidthPercent}%` });
}
function handleTouchEnd() {
resizing = false;
resizingInitialMouseX = 0;
resizingInitialWidthPercent = 0;
}
onMount(() => {
// Attach id to nodeRef
nodeRef = document.getElementById('resizable-container-media') as HTMLDivElement;
// Mouse events
window.addEventListener('mousemove', resize);
window.addEventListener('mouseup', endResize);
// Touch events
window.addEventListener('touchmove', handleTouchMove);
window.addEventListener('touchend', handleTouchEnd);
});
onDestroy(() => {
window.removeEventListener('mousemove', resize);
window.removeEventListener('mouseup', endResize);
window.removeEventListener('touchmove', handleTouchMove);
window.removeEventListener('touchend', handleTouchEnd);
});
</script>
<NodeViewWrapper
id="resizable-container-media"
style={`width: ${node.attrs.width}`}
class={`edra-media-container ${selected ? 'selected' : ''} align-${node.attrs.align}`}
>
<div class={`edra-media-group ${resizing ? 'resizing' : ''}`}>
{@render children()}
{#if caption !== null}
<input bind:value={caption} type="text" class="edra-media-caption" />
{/if}
{#if editor?.isEditable}
<div
role="button"
tabindex="0"
aria-label="Resize left"
class="edra-media-resize-handle edra-media-resize-handle-left"
onmousedown={(event: MouseEvent) => {
handleResizingPosition(event, 'left');
}}
ontouchstart={(event: TouchEvent) => {
handleTouchStart(event, 'left');
}}
>
<div class="edra-media-resize-indicator"></div>
</div>
<div
role="button"
tabindex="0"
aria-label="Resize right"
class="edra-media-resize-handle edra-media-resize-handle-right"
onmousedown={(event: MouseEvent) => {
handleResizingPosition(event, 'right');
}}
ontouchstart={(event: TouchEvent) => {
handleTouchStart(event, 'right');
}}
>
<div class="edra-media-resize-indicator"></div>
</div>
<div class="edra-media-toolbar edra-media-toolbar-audio">
<button
class={`edra-toolbar-button ${node.attrs.align === 'left' ? 'active' : ''}`}
onclick={() => updateAttributes({ align: 'left' })}
title="Align Left"
>
<AlignLeft />
</button>
<button
class={`edra-toolbar-button ${node.attrs.align === 'center' ? 'active' : ''}`}
onclick={() => updateAttributes({ align: 'center' })}
title="Align Center"
>
<AlignCenter />
</button>
<button
class={`edra-toolbar-button ${node.attrs.align === 'right' ? 'active' : ''}`}
onclick={() => updateAttributes({ align: 'right' })}
title="Align Right"
>
<AlignRight />
</button>
<button
class="edra-toolbar-button"
onclick={() => {
if (caption === null || caption.trim() === '') caption = 'Audio Caption';
}}
title="Caption"
>
<Captions />
</button>
<button
class="edra-toolbar-button"
onclick={() => {
duplicateContent(editor, node);
}}
title="Duplicate"
>
<CopyIcon />
</button>
<button
class="edra-toolbar-button"
onclick={() => {
updateAttributes({
width: 'fit-content'
});
}}
title="Full Screen"
>
<Fullscreen />
</button>
<button
class="edra-toolbar-button edra-destructive"
onclick={() => {
deleteNode();
}}
title="Delete"
>
<Trash />
</button>
</div>
{/if}
</div>
</NodeViewWrapper>

View file

@ -0,0 +1,114 @@
<script lang="ts">
interface Props {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
props: Record<string, any>;
}
const { props }: Props = $props();
let scrollContainer = $state<HTMLElement | null>(null);
let selectedGroupIndex = $state<number>(0);
let selectedCommandIndex = $state<number>(0);
const items = $derived.by(() => props.items);
$effect(() => {
if (items) {
selectedGroupIndex = 0;
selectedCommandIndex = 0;
}
});
$effect(() => {
const activeItem = document.getElementById(`${selectedGroupIndex}-${selectedCommandIndex}`);
if (activeItem !== null && scrollContainer !== null) {
const offsetTop = activeItem.offsetTop;
const offsetHeight = activeItem.offsetHeight;
scrollContainer.scrollTop = offsetTop - offsetHeight;
}
});
const selectItem = (groupIndex: number, commandIndex: number) => {
const command = props.items[groupIndex].commands[commandIndex];
props.command(command);
};
function handleKeyDown(e: KeyboardEvent) {
if (e.key === 'ArrowDown' || ((e.ctrlKey || e.metaKey) && e.key === 'j') || e.key === 'Tab') {
e.preventDefault();
if (!props.items.length) {
return false;
}
const commands = props.items[selectedGroupIndex].commands;
let newCommandIndex = selectedCommandIndex + 1;
let newGroupIndex = selectedGroupIndex;
if (commands.length - 1 < newCommandIndex) {
newCommandIndex = 0;
newGroupIndex = selectedGroupIndex + 1;
}
if (props.items.length - 1 < newGroupIndex) {
newGroupIndex = 0;
}
selectedCommandIndex = newCommandIndex;
selectedGroupIndex = newGroupIndex;
return true;
}
if (e.key === 'ArrowUp' || ((e.ctrlKey || e.metaKey) && e.key === 'k')) {
e.preventDefault();
if (!props.items.length) {
return false;
}
let newCommandIndex = selectedCommandIndex - 1;
let newGroupIndex = selectedGroupIndex;
if (newCommandIndex < 0) {
newGroupIndex = selectedGroupIndex - 1;
newCommandIndex = props.items[newGroupIndex]?.commands.length - 1 || 0;
}
if (newGroupIndex < 0) {
newGroupIndex = props.items.length - 1;
newCommandIndex = props.items[newGroupIndex].commands.length - 1;
}
selectedCommandIndex = newCommandIndex;
selectedGroupIndex = newGroupIndex;
return true;
}
if (e.key === 'Enter') {
e.preventDefault();
if (!props.items.length || selectedGroupIndex === -1 || selectedCommandIndex === -1) {
return false;
}
selectItem(selectedGroupIndex, selectedCommandIndex);
return true;
}
return false;
}
</script>
<svelte:window onkeydown={handleKeyDown} />
{#if items.length}
<div bind:this={scrollContainer} class="edra-slash-command-list">
{#each items as grp, groupIndex (groupIndex)}
<span class="edra-slash-command-list-title">{grp.title}</span>
{#each grp.commands as command, commandIndex (commandIndex)}
{@const Icon = command.icon}
{@const isActive =
selectedGroupIndex === groupIndex && selectedCommandIndex === commandIndex}
<button
id={`${groupIndex}-${commandIndex}`}
class="edra-slash-command-list-item"
class:active={isActive}
onclick={() => selectItem(groupIndex, commandIndex)}
>
<Icon class="edra-toolbar-icon" />
<span>{command.tooltip}</span>
</button>
{/each}
{/each}
</div>
{/if}

View file

@ -0,0 +1,26 @@
<script lang="ts">
import type { EdraToolBarCommands } from '../../commands/types.js';
import { type Editor } from '@tiptap/core';
interface Props {
editor: Editor;
command: EdraToolBarCommands;
}
const { editor, command }: Props = $props();
</script>
{#snippet ToolBarIcon({ command, editor }: Props)}
{@const Icon = command.icon}
<button
class="edra-command-button"
class:active={command.isActive?.(editor)}
onclick={() => command.onClick?.(editor)}
disabled={command.clickable ? !command.clickable(editor) : false}
title={`${command.tooltip} ${command.shortCut}`}
>
<Icon class="edra-toolbar-icon" />
</button>
{/snippet}
{@render ToolBarIcon({ command, editor })}

View file

@ -0,0 +1,15 @@
<script lang="ts">
import type { NodeViewProps } from '@tiptap/core';
import MediaExtended from './MediaExtended.svelte';
const { ...rest }: NodeViewProps = $props();
let mediaRef = $state<HTMLElement>();
</script>
<MediaExtended bind:mediaRef {...rest}>
{@const node = rest.node}
<video bind:this={mediaRef} src={node.attrs.src} controls title={node.attrs.title}>
<track kind="captions" />
</video>
</MediaExtended>

View file

@ -0,0 +1,21 @@
<script lang="ts">
import MediaPlaceHolder from '../../components/MediaPlaceHolder.svelte';
import type { NodeViewProps } from '@tiptap/core';
const { editor }: NodeViewProps = $props();
import Video from '@lucide/svelte/icons/video';
function handleClick() {
const videoUrl = prompt('Please enter the video URL');
if (videoUrl) {
editor.chain().focus().setVideo(videoUrl).run();
}
}
</script>
<MediaPlaceHolder
class="edra-media-placeholder-wrapper"
icon={Video}
title="Insert a video"
onClick={handleClick}
/>

View file

@ -0,0 +1,52 @@
<script lang="ts">
import { Editor } from '@tiptap/core';
interface Props {
editor: Editor;
}
const { editor }: Props = $props();
const FONT_SIZE = [
{ label: 'Tiny', value: '0.7rem' },
{ label: 'Smaller', value: '0.75rem' },
{ label: 'Small', value: '0.9rem' },
{ label: 'Default', value: '' },
{ label: 'Large', value: '1.25rem' },
{ label: 'Extra Large', value: '1.5rem' }
];
let currentSize = $derived.by(() => editor.getAttributes('textStyle').fontSize || '');
</script>
<select
value={currentSize}
onchange={(e) => {
editor
.chain()
.focus()
.setFontSize((e.target as HTMLSelectElement).value)
.run();
}}
title="Font Size"
>
{#each FONT_SIZE as fontSize (fontSize)}
<option value={fontSize.value} label={fontSize.label.split(' ')[0]}></option>
{/each}
</select>
<style>
select {
display: inline-flex;
align-items: center;
justify-content: center;
border: none;
background-color: var(--edra-button-bg-color);
border-radius: var(--edra-button-border-radius);
cursor: pointer;
transition: background-color 0.2s ease-in-out;
padding: var(--edra-button-padding);
min-width: fit;
min-height: full;
}
</style>

View file

@ -0,0 +1,70 @@
<script lang="ts">
import type { Editor } from '@tiptap/core';
interface Props {
editor: Editor;
}
const { editor }: Props = $props();
const colors = [
{ label: 'Default', value: '' },
{ label: 'Blue', value: '#0000FF' },
{ label: 'Brown', value: '#A52A2A' },
{ label: 'Green', value: '#008000' },
{ label: 'Grey', value: '#808080' },
{ label: 'Orange', value: '#FFA500' },
{ label: 'Pink', value: '#FFC0CB' },
{ label: 'Purple', value: '#800080' },
{ label: 'Red', value: '#FF0000' },
{ label: 'Yellow', value: '#FFFF00' }
];
const currentColor = $derived.by(() => editor.getAttributes('textStyle').color ?? '');
const currentHighlight = $derived.by(() => editor.getAttributes('highlight').color ?? '');
</script>
<select
value={currentColor}
onchange={(e) => {
const color = (e.target as HTMLSelectElement).value;
editor.chain().focus().setColor(color).run();
}}
style={`color: ${currentColor}`}
title="Text Color"
>
<option value="" label="Default"></option>
{#each colors as color (color)}
<option value={color.value} label={color.label}></option>
{/each}
</select>
<select
value={currentHighlight}
onchange={(e) => {
const color = (e.target as HTMLSelectElement).value;
editor.chain().focus().setHighlight({ color }).run();
}}
style={`background-color: ${currentHighlight}50`}
title="Hightlight Color"
>
<option value="" label="Default"></option>
{#each colors as color (color)}
<option value={color.value} label={color.label}>A</option>
{/each}
</select>
<style>
select {
display: inline-flex;
align-items: center;
justify-content: center;
border: none;
background-color: var(--edra-button-bg-color);
border-radius: var(--edra-button-border-radius);
cursor: pointer;
transition: background-color 0.2s ease-in-out;
padding: var(--edra-button-padding);
min-width: fit;
min-height: var(--edra-button-size);
}
</style>

View file

@ -0,0 +1,120 @@
<script lang="ts">
import type { Editor } from '@tiptap/core';
import ArrowLeft from '@lucide/svelte/icons/arrow-left';
import ArrowRight from '@lucide/svelte/icons/arrow-right';
import CaseSensitive from '@lucide/svelte/icons/case-sensitive';
import Replace from '@lucide/svelte/icons/replace';
import ReplaceAll from '@lucide/svelte/icons/replace-all';
import Search from '@lucide/svelte/icons/search';
interface Props {
editor: Editor;
show: boolean;
}
let { editor, show = $bindable(false) }: Props = $props();
let searchText = $state('');
let replaceText = $state('');
let caseSensitive = $state(false);
let searchIndex = $derived(editor.storage?.searchAndReplace?.resultIndex);
let searchCount = $derived(editor.storage?.searchAndReplace?.results.length);
function updateSearchTerm(clearIndex: boolean = false) {
if (clearIndex) editor.commands.resetIndex();
editor.commands.setSearchTerm(searchText);
editor.commands.setReplaceTerm(replaceText);
editor.commands.setCaseSensitive(caseSensitive);
}
function goToSelection() {
const { results, resultIndex } = editor.storage.searchAndReplace;
const position = results[resultIndex];
if (!position) return;
editor.commands.setTextSelection(position);
const { node } = editor.view.domAtPos(editor.state.selection.anchor);
if (node instanceof HTMLElement) node.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
function replace() {
editor.commands.replace();
goToSelection();
}
const next = () => {
editor.commands.nextSearchResult();
goToSelection();
};
const previous = () => {
editor.commands.previousSearchResult();
goToSelection();
};
const clear = () => {
searchText = '';
replaceText = '';
caseSensitive = false;
editor.commands.resetIndex();
};
const replaceAll = () => editor.commands.replaceAll();
</script>
<div class="edra-search-and-replace">
<button
class="edra-command-button"
onclick={() => {
show = !show;
clear();
updateSearchTerm();
}}
title={show ? 'Go Back' : 'Search and Replace'}
>
{#if show}
<ArrowLeft class="edra-toolbar-icon" />
{:else}
<Search class="edra-toolbar-icon" />
{/if}
</button>
{#if show}
<div class="edra-search-and-replace-content">
<input placeholder="Search..." bind:value={searchText} oninput={() => updateSearchTerm()} />
<span>{searchCount > 0 ? searchIndex + 1 : 0}/{searchCount}</span>
<button
class="edra-command-button"
class:active={caseSensitive}
onclick={() => {
caseSensitive = !caseSensitive;
updateSearchTerm();
}}
title="Case Sensitive"
>
<CaseSensitive class="edra-toolbar-icon" />
</button>
<button class="edra-command-button" onclick={previous} title="Previous">
<ArrowLeft class="edra-toolbar-icon" />
</button>
<button class="edra-command-button" onclick={next} title="Next">
<ArrowRight class="edra-toolbar-icon" />
</button>
<span class="separator"></span>
<input placeholder="Replace..." bind:value={replaceText} oninput={() => updateSearchTerm()} />
<button class="edra-command-button" onclick={replace} title="Replace">
<Replace class="edra-toolbar-icon" />
</button>
<button class="edra-command-button" onclick={replaceAll} title="Replace All">
<ReplaceAll class="edra-toolbar-icon" />
</button>
</div>
{/if}
</div>
<style>
.separator {
width: 1rem;
}
</style>

View file

@ -0,0 +1,109 @@
<script lang="ts">
import { onDestroy, onMount } from 'svelte';
import type { EdraEditorProps } from '../types.js';
import initEditor from '../editor.js';
import { focusEditor } from '../utils.js';
import '../editor.css';
import './style.css';
import '../onedark.css';
import { ImagePlaceholder } from '../extensions/image/ImagePlaceholder.js';
import ImagePlaceholderComp from './components/ImagePlaceholder.svelte';
import { ImageExtended } from '../extensions/image/ImageExtended.js';
import ImageExtendedComp from './components/ImageExtended.svelte';
import { VideoPlaceholder } from '../extensions/video/VideoPlaceholder.js';
import VideoPlaceHolderComp from './components/VideoPlaceholder.svelte';
import { VideoExtended } from '../extensions/video/VideoExtended.js';
import VideoExtendedComp from './components/VideoExtended.svelte';
import { AudioPlaceholder } from '../extensions/audio/AudioPlaceholder.js';
import { AudioExtended } from '../extensions/audio/AudiExtended.js';
import AudioPlaceHolderComp from './components/AudioPlaceHolder.svelte';
import AudioExtendedComp from './components/AudioExtended.svelte';
import { IFramePlaceholder } from '../extensions/iframe/IFramePlaceholder.js';
import { IFrameExtended } from '../extensions/iframe/IFrameExtended.js';
import IFramePlaceHolderComp from './components/IFramePlaceHolder.svelte';
import IFrameExtendedComp from './components/IFrameExtended.svelte';
import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight';
import { all, createLowlight } from 'lowlight';
import { SvelteNodeViewRenderer } from 'svelte-tiptap';
import CodeBlock from './components/CodeBlock.svelte';
import TableCol from './menus/TableCol.svelte';
import TableRow from './menus/TableRow.svelte';
import Link from './menus/Link.svelte';
import slashcommand from '../extensions/slash-command/slashcommand.js';
import SlashCommandList from './components/SlashCommandList.svelte';
const lowlight = createLowlight(all);
/**
* Bind the element to the editor
*/
let element = $state<HTMLElement>();
let {
editor = $bindable(),
editable = true,
content,
onUpdate,
autofocus = false,
class: className
}: EdraEditorProps = $props();
onMount(() => {
editor = initEditor(
element,
content,
[
CodeBlockLowlight.configure({
lowlight
}).extend({
addNodeView() {
return SvelteNodeViewRenderer(CodeBlock);
}
}),
ImagePlaceholder(ImagePlaceholderComp),
ImageExtended(ImageExtendedComp),
VideoPlaceholder(VideoPlaceHolderComp),
VideoExtended(VideoExtendedComp),
AudioPlaceholder(AudioPlaceHolderComp),
AudioExtended(AudioExtendedComp),
IFramePlaceholder(IFramePlaceHolderComp),
IFrameExtended(IFrameExtendedComp),
slashcommand(SlashCommandList)
],
{
onUpdate,
onTransaction(props) {
// Only update if editor instance actually changed
// The old pattern (editor = undefined; editor = props.editor) was a Svelte 4
// workaround that causes infinite loops in Svelte 5's fine-grained reactivity
if (editor !== props.editor) {
editor = props.editor;
}
},
editable,
autofocus
}
);
});
onDestroy(() => {
if (editor) editor.destroy();
});
</script>
{#if editor && !editor.isDestroyed}
<Link {editor} />
<TableCol {editor} />
<TableRow {editor} />
{/if}
<div
bind:this={element}
role="button"
tabindex="0"
onclick={(event) => focusEditor(editor, event)}
onkeydown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
focusEditor(editor, event);
}
}}
class={`edra-editor ${className}`}
></div>

View file

@ -0,0 +1,3 @@
export { default as EdraEditor } from './editor.svelte';
export { default as EdraToolBar } from './toolbar.svelte';
export { default as EdraBubbleMenu } from './menus/Menu.svelte';

View file

@ -0,0 +1,44 @@
<script lang="ts">
import type { ShouldShowProps } from '../../types.js';
import BubbleMenu from '../../components/BubbleMenu.svelte';
import type { Editor } from '@tiptap/core';
import Copy from '@lucide/svelte/icons/copy';
import Trash from '@lucide/svelte/icons/trash';
interface Props {
editor: Editor;
}
const { editor }: Props = $props();
let link = $derived.by(() => editor.getAttributes('link').href);
</script>
<BubbleMenu
{editor}
pluginKey="link-bubble-menu"
shouldShow={(props: ShouldShowProps) => {
if (!props.editor.isEditable) return false;
return props.editor.isActive('link');
}}
>
<a href={link} target="_blank">
{link}
</a>
<button
title="Copy Link"
class="edra-command-button"
onclick={() => {
navigator.clipboard.writeText(link);
}}
>
<Copy class="edra-toolbar-icon" />
</button>
<button
class="edra-command-button"
title="Remove Link"
onclick={() => editor.chain().focus().extendMarkRange('link').unsetLink().run()}
>
<Trash class="edra-toolbar-icon" />
</button>
</BubbleMenu>

View file

@ -0,0 +1,96 @@
<script lang="ts">
import commands from '../../commands/toolbar-commands.js';
import BubbleMenu from '../../components/BubbleMenu.svelte';
import type { EdraToolbarProps, ShouldShowProps } from '../../types.js';
import { isTextSelection } from '@tiptap/core';
import FontSize from '../components/toolbar/FontSize.svelte';
import QuickColors from '../components/toolbar/QuickColors.svelte';
import ToolBarIcon from '../components/ToolBarIcon.svelte';
const {
editor,
class: className,
excludedCommands = ['undo-redo', 'headings', 'media', 'lists', 'table']
}: EdraToolbarProps = $props();
const toolbarCommands = Object.keys(commands).filter((key) => !excludedCommands?.includes(key));
let isDragging = $state(false);
editor.view.dom.addEventListener('dragstart', () => {
isDragging = true;
});
editor.view.dom.addEventListener('drop', () => {
isDragging = true;
// Allow some time for the drop action to complete before re-enabling
setTimeout(() => {
isDragging = false;
}, 100); // Adjust delay if needed
});
function shouldShow(props: ShouldShowProps) {
if (!props.editor.isEditable) return false;
const { view, editor } = props;
if (!view || editor.view.dragging) {
return false;
}
if (editor.isActive('link')) return false;
if (editor.isActive('codeBlock')) return false;
if (editor.isActive('image-placeholder')) return false;
if (editor.isActive('video-placeholder')) return false;
if (editor.isActive('audio-placeholder')) return false;
if (editor.isActive('iframe-placeholder')) return false;
const {
state: {
doc,
selection,
selection: { empty, from, to }
}
} = editor;
// check if the selection is a table grip
const domAtPos = view.domAtPos(from || 0).node as HTMLElement;
const nodeDOM = view.nodeDOM(from || 0) as HTMLElement;
const node = nodeDOM || domAtPos;
if (isTableGripSelected(node)) {
return false;
}
// Sometime check for `empty` is not enough.
// Doubleclick an empty paragraph returns a node size of 2.
// So we check also for an empty text size.
const isEmptyTextBlock = !doc.textBetween(from, to).length && isTextSelection(selection);
if (empty || isEmptyTextBlock || !editor.isEditable) {
return false;
}
return !isDragging && !editor.state.selection.empty;
}
const isTableGripSelected = (node: HTMLElement) => {
let container = node;
while (container && !['TD', 'TH'].includes(container.tagName)) {
container = container.parentElement!;
}
const gripColumn =
container && container.querySelector && container.querySelector('a.grip-column.selected');
const gripRow =
container && container.querySelector && container.querySelector('a.grip-row.selected');
if (gripColumn || gripRow) {
return true;
}
return false;
};
</script>
<BubbleMenu {editor} class={className} pluginKey="link-bubble-menu" {shouldShow}>
{#each toolbarCommands.filter((c) => !excludedCommands?.includes(c)) as cmd (cmd)}
{@const commandGroup = commands[cmd]}
{#each commandGroup as command (command)}
<ToolBarIcon {editor} {command} />
{/each}
{/each}
<FontSize {editor} />
<QuickColors {editor} />
</BubbleMenu>

View file

@ -0,0 +1,53 @@
<script lang="ts">
import { type Editor } from '@tiptap/core';
import ArrowLeftFromLine from '@lucide/svelte/icons/arrow-left-from-line';
import ArrowRightFromLine from '@lucide/svelte/icons/arrow-right-from-line';
import Trash from '@lucide/svelte/icons/trash';
import type { ShouldShowProps } from '../../types.js';
import { isColumnGripSelected } from '../../extensions/table/utils.js';
import BubbleMenu from '../../components/BubbleMenu.svelte';
interface Props {
editor: Editor;
}
let { editor }: Props = $props();
</script>
<BubbleMenu
{editor}
pluginKey="table-col-menu"
shouldShow={(props: ShouldShowProps) => {
if (!props.editor.isEditable) return false;
if (!props.state) {
return false;
}
return isColumnGripSelected({
editor: props.editor,
view: props.view,
state: props.state,
from: props.from
});
}}
>
<button
class="edra-command-button"
title="Add Column After"
onclick={() => editor.chain().focus().addColumnAfter().run()}
>
<ArrowRightFromLine class="edra-toolbar-icon" />
</button>
<button
class="edra-command-button"
title="Add Column Before"
onclick={() => editor.chain().focus().addColumnBefore().run()}
>
<ArrowLeftFromLine class="edra-toolbar-icon" />
</button>
<button
class="edra-command-button"
title="Delete Column"
onclick={() => editor.chain().focus().deleteColumn().run()}
>
<Trash class="edra-toolbar-icon" />
</button>
</BubbleMenu>

View file

@ -0,0 +1,53 @@
<script lang="ts">
import { type Editor } from '@tiptap/core';
import ArrowDownFromLine from '@lucide/svelte/icons/arrow-down-from-line';
import ArrowUpFromLine from '@lucide/svelte/icons/arrow-up-from-line';
import Trash from '@lucide/svelte/icons/trash';
import type { ShouldShowProps } from '../../types.js';
import { isRowGripSelected } from '../../extensions/table/utils.js';
import BubbleMenu from '../../components/BubbleMenu.svelte';
interface Props {
editor: Editor;
}
let { editor }: Props = $props();
</script>
<BubbleMenu
{editor}
pluginKey="table-row-menu"
shouldShow={(props: ShouldShowProps) => {
if (!props.editor.isEditable) return false;
if (!props.state) {
return false;
}
return isRowGripSelected({
editor: props.editor,
view: props.view,
state: props.state,
from: props.from
});
}}
>
<button
class="edra-command-button"
title="Add Column After"
onclick={() => editor.chain().focus().addRowAfter().run()}
>
<ArrowDownFromLine class="edra-toolbar-icon" />
</button>
<button
class="edra-command-button"
title="Add Column Before"
onclick={() => editor.chain().focus().addRowBefore().run()}
>
<ArrowUpFromLine class="edra-toolbar-icon" />
</button>
<button
class="edra-command-button"
title="Delete Column"
onclick={() => editor.chain().focus().deleteRow().run()}
>
<Trash class="edra-toolbar-icon" />
</button>
</BubbleMenu>

View file

@ -0,0 +1,340 @@
:root {
/* Color Variables */
--edra-border-color: #80808050;
--edra-button-bg-color: #80808025;
--edra-button-hover-bg-color: #80808075;
--edra-button-active-bg-color: #80808090;
--edra-icon-color: currentColor; /* Default, can be customized */
--edra-separator-color: currentColor; /* Default, can be customized */
/* Size and Spacing Variables */
--edra-gap: 0.25rem;
--edra-border-radius: 0.5rem;
--edra-button-border-radius: 0.5rem;
--edra-padding: 0.5rem;
--edra-button-padding: 0.25rem;
--edra-button-size: 2rem;
--edra-icon-size: 1rem;
--edra-separator-width: 0.25rem;
}
/** Editor Styles */
:root {
--border-color: rgba(128, 128, 128, 0.3);
--border-color-hover: rgba(128, 128, 128, 0.5);
--blockquote-border: rgba(128, 128, 128, 0.7);
--code-color: rgb(255, 68, 0);
--code-bg: rgba(128, 128, 128, 0.3);
--code-border: rgba(128, 128, 128, 0.4);
--table-border: rgba(128, 128, 128, 0.3);
--table-bg-selected: rgba(128, 128, 128, 0.1);
--table-bg-hover: rgba(128, 128, 128, 0.2);
--task-completed-color: rgba(128, 128, 128, 0.7);
--code-wrapper-bg: rgba(128, 128, 128, 0.05);
--highlight-color: rgba(0, 128, 0, 0.3);
--highlight-border: greenyellow;
--search-result-bg: yellow;
--search-result-current-bg: orange;
}
.edra-editor {
height: 100%;
width: 100%;
cursor: auto;
outline: none !important;
}
.tiptap,
.ProseMirror {
outline: none !important;
}
.tiptap a {
color: blue;
text-decoration: underline;
}
.edra-command-button {
display: inline-flex;
align-items: center;
justify-content: center;
border: none;
background-color: var(--edra-button-bg-color);
border-radius: var(--edra-button-border-radius);
cursor: pointer;
transition: background-color 0.2s ease-in-out;
padding: var(--edra-button-padding);
min-width: var(--edra-button-size);
min-height: var(--edra-button-size);
}
.edra-command-button:hover {
background-color: var(--edra-button-hover-bg-color);
}
.edra-command-button.active {
background-color: var(--edra-button-active-bg-color);
}
.edra-toolbar-icon {
height: var(--edra-icon-size);
width: var(--edra-icon-size);
color: var(--edra-icon-color);
}
.edra-media-placeholder-wrapper {
height: 100%;
width: 100%;
padding: 1rem;
padding-right: 0;
margin: 0.5rem 0;
background-color: var(--edra-button-bg-color);
border-radius: var(--edra-button-border-radius);
border: 1px solid var(--edra-border-color);
display: inline-flex;
align-items: center;
justify-content: start;
gap: 1rem;
cursor: pointer;
}
.edra-media-placeholder-icon {
height: var(--edra-icon-size);
width: var(--edra-icon-size);
color: var(--edra-icon-color);
}
.edra-media-container {
position: relative;
display: flex;
flex-direction: column;
border-radius: 0.5rem;
border: 2px solid transparent;
margin: 1rem 0;
}
.edra-media-container.selected {
border-color: #808080;
}
.edra-media-container.align-left {
left: 0;
transform: translateX(0);
}
.edra-media-container.align-center {
left: 50%;
transform: translateX(-50%);
}
.edra-media-container.align-right {
left: 100%;
transform: translateX(-100%);
}
.edra-media-group {
position: relative;
display: flex;
flex-direction: column;
border-radius: 0.5rem;
}
.edra-media-content {
margin: 0;
object-fit: cover;
}
.edra-media-caption {
margin: 0.125rem 0;
width: 100%;
background-color: transparent;
text-align: center;
font-size: 0.85rem;
font-weight: 500;
color: #808080;
outline: none;
border: none;
}
.edra-media-resize-handle {
position: absolute;
top: 0;
bottom: 0;
z-index: 20;
display: flex;
width: 0.5rem;
cursor: col-resize;
align-items: center;
}
.edra-media-resize-handle-left {
left: 0;
justify-content: flex-start;
padding: 0.5rem;
}
.edra-media-resize-handle-right {
right: 0;
justify-content: flex-end;
padding: 0.5rem;
}
.edra-media-resize-indicator {
z-index: 20;
height: 3rem;
width: 0.25rem;
border-radius: 12px;
border: 1px solid #808080;
background-color: #808080;
opacity: 0;
transition: opacity 0.5s;
}
.edra-media-group:hover .edra-media-resize-indicator {
opacity: 0.5;
}
.edra-media-toolbar {
position: absolute;
right: 16px;
top: 8px;
display: flex;
align-items: center;
gap: 4px;
border-radius: 4px;
border: 1px solid #808080;
background-color: #80808075;
padding: 4px;
opacity: 0;
transition: opacity 0.2s;
}
.edra-media-toolbar-audio {
top: -32px;
}
.edra-media-group:hover .edra-media-toolbar,
.edra-media-toolbar.visible {
opacity: 1;
}
.edra-toolbar-button {
width: 1.5rem;
height: 1.5rem;
display: flex;
align-items: center;
justify-content: center;
background: none;
border: none;
border-radius: 4px;
cursor: pointer;
color: currentColor;
}
.edra-toolbar-button:hover {
background-color: #80808030;
}
.edra-toolbar-button.active {
background-color: #80808080;
}
.edra-destructive {
color: red;
}
.bubble-menu-wrapper {
z-index: 100;
width: fit-content;
padding: 0.25rem;
border: 1px solid var(--edra-border-color);
border-radius: 0.5rem;
display: flex;
align-items: center;
gap: 0.25rem;
background-color: white;
backdrop-filter: blur(8px);
}
html.dark .bubble-menu-wrapper {
background-color: black;
}
.bubble-menu-wrapper input {
padding: 0.5rem;
border: none;
max-width: 10rem;
background: none;
margin-right: 0.5rem;
width: fit-content;
}
input.valid {
border: 1px solid green;
}
input:focus {
outline: none;
}
input.invalid {
border: 1px solid red;
}
.edra-slash-command-list {
margin-bottom: 2rem;
max-height: min(80vh, 20rem);
width: fit-content;
overflow: auto;
scroll-behavior: smooth;
border-radius: 0.5rem;
border: 1px solid var(--edra-border-color);
padding: 0.5rem;
backdrop-filter: blur(8px);
}
.edra-slash-command-list-title {
margin: 0.5rem;
user-select: none;
font-size: 0.875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.025em;
}
.edra-slash-command-list-item {
display: flex;
height: fit-content;
width: 100%;
align-items: center;
justify-content: flex-start;
gap: 0.5rem;
padding: 0.5rem;
background: none;
border: none;
margin: 0.25rem 0;
border-radius: 0.25rem;
}
.edra-slash-command-list-item.active {
background-color: var(--edra-border-color);
}
.edra-search-and-replace {
display: flex;
align-items: center;
gap: var(--edra-gap);
}
.edra-search-and-replace-content {
display: flex;
align-items: center;
gap: var(--edra-gap);
}
.edra-search-and-replace-content input {
max-width: 10rem;
background: none;
width: 15rem;
border: 1px solid var(--edra-border-color);
border-radius: var(--edra-button-border-radius);
padding: 0.2rem 0.5rem;
}

View file

@ -0,0 +1,32 @@
<script lang="ts">
import commands from '../commands/toolbar-commands.js';
import type { EdraToolbarProps } from '../types.js';
import FontSize from './components/toolbar/FontSize.svelte';
import QuickColors from './components/toolbar/QuickColors.svelte';
import SearchAndReplace from './components/toolbar/SearchAndReplace.svelte';
import ToolBarIcon from './components/ToolBarIcon.svelte';
const { editor, class: className, excludedCommands, children }: EdraToolbarProps = $props();
const toolbarCommands = Object.keys(commands).filter((key) => !excludedCommands?.includes(key));
let show = $state<boolean>(false);
</script>
<div class={`edra-toolbar ${className}`}>
{#if children}
{@render children()}
{:else}
{#if !show}
{#each toolbarCommands as cmd (cmd)}
{@const commandGroup = commands[cmd]}
{#each commandGroup as command (command)}
<ToolBarIcon {editor} {command} />
{/each}
{/each}
<FontSize {editor} />
<QuickColors {editor} />
{/if}
<SearchAndReplace {editor} bind:show />
{/if}
</div>

View file

@ -0,0 +1,176 @@
/* One Dark and Light Theme for Highlight.js using Pure CSS */
/* Light Theme (Default) */
.tiptap pre code {
color: #383a42;
}
/* Comment */
.hljs-comment,
.hljs-quote {
font-style: italic;
color: #a0a1a7;
}
/* Red */
.hljs-variable,
.hljs-template-variable,
.hljs-tag,
.hljs-name,
.hljs-selector-id,
.hljs-selector-class,
.hljs-regexp,
.hljs-deletion {
color: #e45649;
}
/* Orange */
.hljs-number,
.hljs-built_in,
.hljs-literal,
.hljs-type,
.hljs-params,
.hljs-meta,
.hljs-link {
color: #986801;
}
/* Yellow */
.hljs-attribute {
color: #c18401;
}
/* Green */
.hljs-string,
.hljs-symbol,
.hljs-bullet,
.hljs-addition {
color: #50a14f;
}
/* Blue */
.hljs-title,
.hljs-section {
color: #4078f2;
}
/* Purple */
.hljs-keyword,
.hljs-selector-tag {
color: #a626a4;
}
/* Cyan */
.hljs-emphasis {
font-style: italic;
color: #0184bc;
}
.hljs-strong {
font-weight: bold;
}
/* Base styles */
.hljs-doctag,
.hljs-formula {
color: #a626a4;
}
.hljs-attr,
.hljs-subst {
color: #383a42;
}
/* Line highlights */
.hljs-addition {
background-color: #e6ffed;
}
.hljs-deletion {
background-color: #ffeef0;
}
/* Dark Theme (All dark styles consolidated in one media query) */
html.dark {
.tiptap pre code {
color: #abb2bf;
}
/* Comment */
.hljs-comment,
.hljs-quote {
color: #5c6370;
}
/* Red */
.hljs-variable,
.hljs-template-variable,
.hljs-tag,
.hljs-name,
.hljs-selector-id,
.hljs-selector-class,
.hljs-regexp,
.hljs-deletion {
color: #e06c75;
}
/* Orange */
.hljs-number,
.hljs-built_in,
.hljs-literal,
.hljs-type,
.hljs-params,
.hljs-meta,
.hljs-link {
color: #d19a66;
}
/* Yellow */
.hljs-attribute {
color: #e5c07b;
}
/* Green */
.hljs-string,
.hljs-symbol,
.hljs-bullet,
.hljs-addition {
color: #98c379;
}
/* Blue */
.hljs-title,
.hljs-section {
color: #61afef;
}
/* Purple */
.hljs-keyword,
.hljs-selector-tag {
color: #c678dd;
}
/* Cyan */
.hljs-emphasis {
color: #56b6c2;
}
/* Base styles */
.hljs-doctag,
.hljs-formula {
color: #c678dd;
}
.hljs-attr,
.hljs-subst {
color: #abb2bf;
}
/* Line highlights */
.hljs-addition {
background-color: #283428;
}
.hljs-deletion {
background-color: #342828;
}
}

View file

@ -0,0 +1,75 @@
import { flushSync, mount, unmount } from 'svelte';
import type { Editor, NodeViewProps } from '@tiptap/core';
interface RendererOptions<P extends Record<string, unknown>> {
editor: Editor;
props: P;
}
type App = ReturnType<typeof mount>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
class SvelteRenderer<R = unknown, P extends Record<string, any> = object> {
id: string;
component: App;
editor: Editor;
props: P;
element: HTMLElement;
ref: R | null = null;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mnt: Record<any, any> | null = null;
constructor(component: App, { props, editor }: RendererOptions<P>) {
this.id = Math.floor(Math.random() * 0xffffffff).toString();
this.component = component;
this.props = props;
this.editor = editor;
this.element = document.createElement('div');
this.element.classList.add('svelte-renderer');
if (this.editor.isInitialized) {
// On first render, we need to flush the render synchronously
// Renders afterwards can be async, but this fixes a cursor positioning issue
flushSync(() => {
this.render();
});
} else {
this.render();
}
}
render(): void {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
this.mnt = mount(this.component as any, {
target: this.element,
props: {
props: this.props
}
});
}
updateProps(props: Partial<NodeViewProps>): void {
Object.assign(this.props, props);
this.destroy();
this.render();
}
updateAttributes(attributes: Record<string, string>): void {
Object.keys(attributes).forEach((key) => {
this.element.setAttribute(key, attributes[key]);
});
this.destroy();
this.render();
}
destroy(): void {
if (this.mnt) {
unmount(this.mnt);
} else {
unmount(this.component);
}
}
}
export default SvelteRenderer;

View file

@ -0,0 +1,30 @@
import type { Content, Editor } from '@tiptap/core';
import type { EditorState } from '@tiptap/pm/state';
import type { EditorView } from '@tiptap/pm/view';
import type { Snippet } from 'svelte';
export interface EdraEditorProps {
content?: Content;
editable?: boolean;
editor?: Editor;
autofocus?: boolean;
onUpdate?: () => void;
class?: string;
}
export interface EdraToolbarProps {
editor: Editor;
class?: string;
excludedCommands?: string[];
children?: Snippet<[]>;
}
export interface ShouldShowProps {
editor: Editor;
element: HTMLElement;
view: EditorView;
state: EditorState;
oldState?: EditorState;
from: number;
to: number;
}

View file

@ -0,0 +1,119 @@
import { browser } from '$app/environment';
import type { Editor } from '@tiptap/core';
import { Decoration, DecorationSet, type EditorView } from '@tiptap/pm/view';
import { Node } from '@tiptap/pm/model';
/**
* Check if the current browser is in mac or not
*/
export const isMac = browser
? navigator.userAgent.includes('Macintosh') || navigator.userAgent.includes('Mac OS X')
: false;
/**
* Function to handle paste event of an image
* @param editor Editor - editor instance
* @param maxSize number - max size of the image to be pasted in MB, default is 2MB
*/
export function getHandlePaste(editor: Editor, maxSize: number = 2) {
return (view: EditorView, event: ClipboardEvent) => {
const item = event.clipboardData?.items[0];
if (item?.type.indexOf('image') !== 0) {
return;
}
const file = item.getAsFile();
if (file === null || file.size === undefined) return;
const filesize = (file?.size / 1024 / 1024).toFixed(4);
if (filesize && Number(filesize) > maxSize) {
window.alert(`too large image! filesize: ${filesize} mb`);
return;
}
const reader = new FileReader();
reader.readAsDataURL(file);
// reader.onload = (e) => {
// if (e.target?.result) {
// editor.commands.setImage({ src: e.target.result as string });
// }
// };
};
}
export const findColors = (doc: Node) => {
const hexColor = /(#[0-9a-f]{3,6})\b/gi;
const decorations: Decoration[] = [];
doc.descendants((node, position) => {
if (!node.text) {
return;
}
Array.from(node.text.matchAll(hexColor)).forEach((match) => {
const color = match[0];
const index = match.index || 0;
const from = position + index;
const to = from + color.length;
const decoration = Decoration.inline(from, to, {
class: 'color',
style: `--color: ${color}`
});
decorations.push(decoration);
});
});
return DecorationSet.create(doc, decorations);
};
/**
* Dupilcate content at the current selection
* @param editor Editor instance
* @param node Node to be duplicated
*/
export const duplicateContent = (editor: Editor, node: Node) => {
const { view } = editor;
const { state } = view;
const { selection } = state;
editor
.chain()
.insertContentAt(selection.to, node.toJSON(), {
updateSelection: true
})
.focus(selection.to)
.run();
};
/**
* Sets focus on the editor and moves the cursor to the clicked text position,
* defaulting to the end of the document if the click is outside any text.
*
* @param editor - Editor instance
* @param event - Optional MouseEvent or KeyboardEvent triggering the focus
*/
export function focusEditor(editor: Editor | undefined, event?: MouseEvent | KeyboardEvent) {
if (!editor) return;
// Check if there is a text selection already (i.e. a non-empty selection)
const selection = window.getSelection();
if (selection && selection.toString().length > 0) {
// Focus the editor without modifying selection
editor.chain().focus().run();
return;
}
if (event instanceof MouseEvent) {
const { clientX, clientY } = event;
const pos = editor.view.posAtCoords({ left: clientX, top: clientY })?.pos;
if (pos == null) {
// If not a valid position, move cursor to the end of the document
const endPos = editor.state.doc.content.size;
editor.chain().focus().setTextSelection(endPos).run();
} else {
editor.chain().focus().setTextSelection(pos).run();
}
} else {
editor.chain().focus().run();
}
}