feat: migrate dashboard to svelte 5 (#577)

* chore: migration to SvelteKit 2

* stage 1: migrate to svelte 5 and fix rerendering issues

* stage 2: fix more $: reactivity across dashboard

* fix merge conflicts

* chore: migrate code in main to svelte 5

* chore: update preventDefault

* chore: simplify tsconfig and remove unused mobileMenuRef in MobileMenu.svelte

* chore: enforce .ts across dashboard

* rename from classroomio-com to website

* stage-3: Replace carbon icon svelte with lucide icon

* update lock file

* chore: finish icon replacement

* fix: settings and setup page multiple rerender

* stage 3: More icon and rerendering fixes

* feat: checkpoint

* feat: update editor

* [not working] connect everything with ui build

* storybook + ui + dashboard all working independently

* add bg-black to +page.svelte

* feat: add editor to storybook

* chore: animate lucid icons (#590)

* v1 animations

* feat: change some course navigation icons & animate org navigation icons

* chore: change settings icon

* chore: use css instead of js for hover

* rename animations

---------

Co-authored-by: tunny17 <[email protected]>

* chore: update path to node_modules types in api tsconfig

* fix: update import path for DropdownMenu component

* fix: add rollupOptions to build configuration in vite.config.ts

* fix: remove rollupOptions from build configuration and add @floating-ui/dom dependency

* install

---------

Co-authored-by: tunny17 <[email protected]>
This commit is contained in:
Rotimi Best
2025-11-01 16:18:29 +01:00
committed by GitHub
co-authored by tunny17
parent 0fb66a3782
commit f5a4156c8b
159 changed files with 15225 additions and 3722 deletions
+242
View File
@@ -0,0 +1,242 @@
# Reusable Editor Component Usage Examples
The Editor component has been rewritten to be fully reusable with configurable props instead of managing internal state.
## Basic Usage
```svelte
<script lang="ts">
import { Editor } from '@cio/ui/custom/editor';
import type { Content } from '@tiptap/core';
let content: Content = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: 'Hello, world!' }]
}
]
};
</script>
<Editor bind:content />
```
## Using HTML Content
You can also initialize the editor with HTML content by converting it to the appropriate Content format:
```svelte
<script lang="ts">
import { Editor } from '@cio/ui/custom/editor';
import type { Content } from '@tiptap/core';
// Example with HTML content
let htmlContent = `
<h1>Welcome to the Editor</h1>
<p>This is a <strong>paragraph</strong> with <em>formatting</em>.</p>
<ul>
<li>First item</li>
<li>Second item with <a href="https://example.com">a link</a></li>
</ul>
<blockquote>
<p>This is a quote block</p>
</blockquote>
`;
// The editor will automatically parse HTML content
let content: Content = htmlContent;
</script>
<Editor bind:content />
```
You can also set HTML content programmatically:
```svelte
<script lang="ts">
import { Editor } from '@cio/ui/custom/editor';
import type { Content, Editor as TiptapEditor } from '@tiptap/core';
let content: Content = { type: 'doc', content: [] };
let editor: TiptapEditor;
function loadHtmlContent() {
const htmlString = `
<h2>Dynamically Loaded Content</h2>
<p>This content was loaded after the editor was ready.</p>
<code>console.log('Hello, world!');</code>
`;
if (editor) {
editor.commands.setContent(htmlString);
}
}
function handleEditorReady(editorInstance: TiptapEditor) {
editor = editorInstance;
}
</script>
<Editor
bind:content
onEditorReady={handleEditorReady}
/>
<button on:click={loadHtmlContent}>
Load HTML Content
</button>
```
## Advanced Usage with All Props
```svelte
<script lang="ts">
import { Editor } from '@cio/ui/custom/editor';
import type { Content, Editor as TiptapEditor } from '@tiptap/core';
let content: Content = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: 'Hello, world!' }]
}
]
};
let editable = true;
let showToolBar = true;
function handleContentChange(newContent: Content) {
console.log('Content changed:', newContent);
// Handle content changes
}
function handleEditorReady(editor: TiptapEditor) {
console.log('Editor ready:', editor);
// Handle editor ready
}
</script>
<Editor
bind:content
bind:editable
{showToolBar}
enablePersistence={true}
contentStorageKey="my-app-content"
editableStorageKey="my-app-editable"
class="custom-editor-wrapper"
editorClass="custom-editor h-96"
onContentChange={handleContentChange}
onEditorReady={handleEditorReady}
/>
```
## Multiple Editors
```svelte
<script lang="ts">
import { Editor } from '@cio/ui/custom/editor';
import type { Content } from '@tiptap/core';
let article: Content = { type: 'doc', content: [] };
let notes: Content = { type: 'doc', content: [] };
</script>
<div class="grid grid-cols-2 gap-4">
<div>
<h2>Article</h2>
<Editor
bind:content={article}
contentStorageKey="article-content"
enablePersistence={true}
/>
</div>
<div>
<h2>Notes</h2>
<Editor
bind:content={notes}
showToolBar={false}
contentStorageKey="notes-content"
enablePersistence={true}
editorClass="h-48"
/>
</div>
</div>
```
## Props Reference
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `content` | `Content` | Default doc with placeholder | Editor content (bindable) |
| `showToolBar` | `boolean` | `true` | Whether to show the toolbar |
| `editable` | `boolean` | `true` | Whether the editor is editable |
| `enablePersistence` | `boolean` | `false` | Enable localStorage persistence |
| `contentStorageKey` | `string` | `'edra-content'` | localStorage key for content |
| `editableStorageKey` | `string` | `'edra-editable'` | localStorage key for editable state |
| `class` | `string` | Default wrapper classes | CSS classes for wrapper |
| `editorClass` | `string` | `'h-[32rem] overflow-auto'` | CSS classes for editor |
| `onContentChange` | `(content: Content) => void` | `undefined` | Callback when content changes |
| `onEditorReady` | `(editor: Editor) => void` | `undefined` | Callback when editor is ready |
| `onEditorDestroy` | `() => void` | `undefined` | Callback when editor is destroyed |
## Callbacks
| Callback | Type | Description |
|----------|------|-------------|
| `onContentChange` | `(content: Content) => void` | Called when editor content changes |
| `onEditorReady` | `(editor: Editor) => void` | Called when editor is ready |
| `onEditorDestroy` | `() => void` | Called when editor is destroyed |
```svelte
<Editor
content={myContent}
onContentChange={(content) => {
console.log('New content:', content);
// Save to database, update state, etc.
}}
onEditorReady={(editor) => {
console.log('Editor is ready, you can focus it:', editor);
editor.commands.focus();
}}
/>
```
## Migration from Old Version
### Before (internal state)
```svelte
<script>
import { Editor } from './editor.svelte';
// No props needed, everything was internal
</script>
<Editor />
```
### After (prop-based)
```svelte
<script>
import { Editor } from './editor.svelte';
import type { Content } from '@tiptap/core';
let content: Content = { type: 'doc', content: [] };
</script>
<Editor bind:content />
```
## Benefits of the Rewrite
1. **Reusability**: Can be used multiple times with different configurations
2. **State Control**: Parent components control the state
3. **Flexibility**: Persistence can be disabled or customized
4. **Callback Handling**: Modern callback props instead of deprecated events
5. **TypeScript Support**: Full type safety with proper interfaces
6. **Customization**: All styling can be overridden via props
@@ -0,0 +1 @@
export { default as ToolBarCommands } from './toolbar-commands';
@@ -0,0 +1,404 @@
import AlighJustify from '@lucide/svelte/icons/align-justify';
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 Audio from '@lucide/svelte/icons/audio-lines';
import Bold from '@lucide/svelte/icons/bold';
import Code from '@lucide/svelte/icons/code';
import type { EdraToolBarCommands } from './types';
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 IFrame from '@lucide/svelte/icons/code-xml';
import Image from '@lucide/svelte/icons/image';
import Italic from '@lucide/svelte/icons/italic';
import Link from '@lucide/svelte/icons/link-2';
import List from '@lucide/svelte/icons/list';
import ListChecks from '@lucide/svelte/icons/list-checks';
import ListOrdered from '@lucide/svelte/icons/list-ordered';
import Quote from '@lucide/svelte/icons/quote';
import Redo from '@lucide/svelte/icons/redo-2';
import StrikeThrough from '@lucide/svelte/icons/strikethrough';
import Subscript from '@lucide/svelte/icons/subscript';
import Superscript from '@lucide/svelte/icons/superscript';
import Table from '@lucide/svelte/icons/table';
import Underline from '@lucide/svelte/icons/underline';
import Undo from '@lucide/svelte/icons/undo-2';
import Video from '@lucide/svelte/icons/video';
import { isMac } from '../utils';
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();
},
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();
},
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();
},
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();
},
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: Bold,
name: 'bold',
tooltip: 'Bold',
shortCut: `${isMac ? '⌘' : 'Ctrl+'}B`,
onClick: (editor) => {
editor.chain().focus().toggleBold().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();
},
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();
},
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();
},
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();
},
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();
},
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();
},
isActive: (editor) => editor.isActive('bulletList')
},
{
icon: ListOrdered,
name: 'orderedList',
tooltip: 'Ordered List',
shortCut: `${isMac ? '⌘⇧' : 'Ctrl+Shift+'}7`,
onClick: (editor) => {
editor.chain().focus().toggleOrderedList().run();
},
isActive: (editor) => editor.isActive('orderedList')
},
{
icon: ListChecks,
name: 'taskList',
tooltip: 'Task List',
shortCut: `${isMac ? '⌘⇧' : 'Ctrl+Shift+'}9`,
onClick: (editor) => {
editor.chain().focus().toggleTaskList().run();
},
isActive: (editor) => 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')
}
]
};
export default commands;
@@ -0,0 +1,12 @@
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;
isActive?: (editor: Editor) => boolean;
clickable?: (editor: Editor) => boolean;
}
@@ -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>
@@ -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';
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>
@@ -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>
+454
View File
@@ -0,0 +1,454 @@
/* Base TipTap Editor Styles with Light/Dark Theme Support */
.tiptap :first-child {
margin-top: 0;
}
/* For Placeholder */
.tiptap .is-empty::before {
pointer-events: none;
float: left;
height: 0;
color: var(--border-color-hover);
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.25rem;
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: opacity;
transition-duration: 200ms;
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;
}
+149
View File
@@ -0,0 +1,149 @@
<script lang="ts">
import type { Content, Editor } from '@tiptap/core';
import type { Transaction } from '@tiptap/pm/state';
import { EdraEditor, EdraToolBar, EdraBubbleMenu, EdraDragHandleExtended } from './ui';
import { slide } from 'svelte/transition';
import { cn } from '$src/tools';
interface Props {
// Content of the editor
content?: Content;
// Whether the toolbar should be visible
showToolBar?: boolean;
// Whether the editor is editable
editable?: boolean;
// Whether to enable localStorage persistence
enablePersistence?: boolean;
// localStorage key for content persistence
contentStorageKey?: string;
// localStorage key for editable state persistence
editableStorageKey?: string;
// CSS class for the editor wrapper
class?: string;
// CSS class for the editor itself
editorClass?: string;
// Callback functions
onContentChange?: (content: Content) => void;
onEditorReady?: (editor: Editor) => void;
onEditorDestroy?: () => void;
}
let {
content = $bindable(getDefaultContent()),
showToolBar = true,
editable = true,
enablePersistence = false,
contentStorageKey = 'edra-content',
editableStorageKey = 'edra-editable',
class: className = '',
editorClass = '',
onContentChange,
onEditorReady,
onEditorDestroy
}: Props = $props();
let editor = $state<Editor>();
// Browser detection
const browser = typeof window !== 'undefined';
// Default content fallback
function getDefaultContent(): Content {
return {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'Start typing...'
}
]
}
]
};
}
// Handle content persistence
$effect(() => {
if (enablePersistence && browser && content) {
localStorage.setItem(contentStorageKey, JSON.stringify(content));
}
});
// Handle editable state persistence
$effect(() => {
if (enablePersistence && browser) {
localStorage.setItem(editableStorageKey, editable.toString());
}
});
// Load persisted content and editable state on mount
$effect(() => {
if (enablePersistence && browser) {
try {
// Load content
const rawContentString = localStorage.getItem(contentStorageKey);
if (rawContentString !== null) {
const persistedContent: Content = JSON.parse(rawContentString);
console.log('persistedContent', persistedContent);
content = persistedContent;
}
// Load editable state
const rawEditableString = localStorage.getItem(editableStorageKey);
if (rawEditableString !== null) {
editable = rawEditableString === 'true';
}
} catch (error) {
console.warn('Failed to load persisted state:', error);
}
}
});
// Handle editor ready
$effect(() => {
if (editor) {
onEditorReady?.(editor);
}
});
function onUpdate(props: { editor: Editor; transaction: Transaction }) {
if (props?.editor && !props.editor.isDestroyed) {
const newContent = props.editor.getJSON();
content = newContent;
onContentChange?.(newContent);
}
}
</script>
{#if browser}
<div
class={cn(
'bg-background z-50 mx-auto mt-12 flex size-full w-[95%] max-w-5xl flex-col rounded-md border border-dashed sm:w-[85%]',
className
)}
>
{#if editor && !editor.isDestroyed}
{#if showToolBar}
<div transition:slide>
<EdraToolBar
class="bg-secondary/50 flex w-full items-center overflow-x-auto border-b border-dashed p-0.5"
{editor}
/>
</div>
{/if}
<EdraBubbleMenu {editor} />
<EdraDragHandleExtended {editor} />
{/if}
<EdraEditor
class={cn('h-[32rem] overflow-auto p-4', editorClass)}
bind:editor
{editable}
content={content || getDefaultContent()}
{onUpdate}
/>
</div>
{/if}
+110
View File
@@ -0,0 +1,110 @@
import { Editor, type Extensions, type EditorOptions, type Content } from '@tiptap/core';
import StarterKit from '@tiptap/starter-kit';
import { getHandlePaste } from './utils';
import Subscript from '@tiptap/extension-subscript';
import Superscript from '@tiptap/extension-superscript';
import Typography from '@tiptap/extension-typography';
import { ColorHighlighter } from './extensions/ColorHighlighter';
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';
import { TaskItem, TaskList } from '@tiptap/extension-list';
import { Table, TableCell, TableRow, TableHeader } from './extensions/table';
import { Placeholder } from '@tiptap/extensions';
import { Markdown } from 'tiptap-markdown';
import MathExtension from '@aarkue/tiptap-math-extension';
import AutoJoiner from 'tiptap-extension-auto-joiner';
import 'katex/dist/katex.min.css';
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 'What’s 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,
MathExtension.configure({ evaluation: true }),
AutoJoiner,
Table,
TableHeader,
TableRow,
TableCell,
Markdown.configure({
html: true,
tightLists: true,
tightListClass: 'tight',
bulletListMarker: '-',
linkify: true,
breaks: true,
transformPastedText: true,
transformCopiedText: false
}),
...(extensions ?? [])
],
...options
});
editor.setOptions({
editorProps: {
handlePaste: getHandlePaste(editor)
}
});
return editor;
};
@@ -0,0 +1,27 @@
import { Extension } from '@tiptap/core';
import { Plugin } from '@tiptap/pm/state';
import { findColors } from '../utils';
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);
}
}
})
];
}
});
@@ -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;
@@ -0,0 +1,34 @@
import { Audio } from './AudioExtension';
import type { Component } from 'svelte';
import type { NodeViewProps } from '@tiptap/core';
import { SvelteNodeViewRenderer } from 'svelte-tiptap';
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);
}
});
@@ -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;
}
}
}
})
];
}
});
@@ -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'
});
}
};
}
});
@@ -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.');
}
@@ -0,0 +1,374 @@
import { Fragment, Node, Slice } from '@tiptap/pm/model';
import { NodeSelection, Plugin, PluginKey, TextSelection } from '@tiptap/pm/state';
import { EditorView } from '@tiptap/pm/view';
import { Extension } from '@tiptap/core';
import { serializeForClipboard } from './ClipboardSerializer';
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;
@@ -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)
};
}
});
@@ -0,0 +1,34 @@
import type { Component } from 'svelte';
import IFrame from './IFrame';
import type { NodeViewProps } from '@tiptap/core';
import { SvelteNodeViewRenderer } from 'svelte-tiptap';
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);
}
});
@@ -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'
});
}
};
}
});
@@ -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
});
};
@@ -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'
});
}
};
}
});
@@ -0,0 +1,57 @@
import type { Editor } from '@tiptap/core';
import type { EdraToolBarCommands } from '../../commands/types';
import Minus from '@lucide/svelte/icons/minus';
import Quote from '@lucide/svelte/icons/quote';
import SquareCode from '@lucide/svelte/icons/square-code';
import commands from '../../commands/toolbar-commands';
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,
{
icon: Minus,
name: 'horizontalRule',
tooltip: 'Horizontal Rule',
onClick: (editor: Editor) => {
editor.chain().focus().setHorizontalRule().run();
}
}
]
}
];
export default GROUPS;
@@ -0,0 +1,255 @@
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';
import SvelteRenderer from '../../svelte-renderer';
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
}
};
}
});
@@ -0,0 +1,4 @@
export { Table } from './table';
export { TableCell } from './table-cell';
export { TableRow } from './table-row';
export { TableHeader } from './table-header';
@@ -0,0 +1,124 @@
import { Decoration, DecorationSet } from '@tiptap/pm/view';
import { Node, mergeAttributes } from '@tiptap/core';
import { getCellsInColumn, isRowSelected, selectRow } from './utils';
import { Plugin } from '@tiptap/pm/state';
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);
}
}
})
];
}
});
@@ -0,0 +1,89 @@
import { Decoration, DecorationSet } from '@tiptap/pm/view';
import { getCellsInRow, isColumnSelected, selectColumn } from './utils';
import { Plugin } from '@tiptap/pm/state';
import { TableHeader as TiptapTableHeader } from '@tiptap/extension-table';
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;
@@ -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;
@@ -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;
@@ -0,0 +1,313 @@
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';
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;
};
@@ -0,0 +1,34 @@
import type { Component } from 'svelte';
import type { NodeViewProps } from '@tiptap/core';
import { SvelteNodeViewRenderer } from 'svelte-tiptap';
import { Video } from './VideoExtension';
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);
}
});
@@ -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;
}
}
}
})
];
}
});
@@ -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'
});
}
};
}
});
+1
View File
@@ -0,0 +1 @@
export { default as Editor } from './editor.svelte';
+176
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;
}
}
@@ -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;
+54
View File
@@ -0,0 +1,54 @@
import type { Content, Editor } from '@tiptap/core';
import type { EditorState, Transaction } 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?: (args: { editor: Editor; transaction: Transaction }) => void;
class?: string;
}
export interface EditorProps {
// Content of the editor
content?: Content;
// Whether the toolbar should be visible
showToolBar?: boolean;
// Whether the editor is editable
editable?: boolean;
// Whether to enable localStorage persistence
enablePersistence?: boolean;
// localStorage key for content persistence
contentStorageKey?: string;
// localStorage key for editable state persistence
editableStorageKey?: string;
// CSS class for the editor wrapper
class?: string;
// CSS class for the editor itself
editorClass?: string;
// Callback functions
onContentChange?: (content: Content) => void;
onEditorReady?: (editor: Editor) => void;
onEditorDestroy?: () => void;
}
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;
}
@@ -0,0 +1,20 @@
<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}
class="m-0 w-full"
>
</audio>
</MediaExtended>
@@ -0,0 +1,22 @@
<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';
import { buttonVariants } from '$src/base/button';
function handleClick() {
const audioUrl = prompt('Please enter the audio URL');
if (audioUrl) {
editor.chain().focus().setAudio(audioUrl).run();
}
}
</script>
<MediaPlaceHolder
class={buttonVariants({ variant: 'secondary', class: 'my-2 w-full justify-start p-6' })}
icon={Audio}
title="Insert an audio"
onClick={handleClick}
/>
@@ -0,0 +1,73 @@
<script lang="ts">
import { NodeViewWrapper, NodeViewContent } from 'svelte-tiptap';
import type { NodeViewProps } from '@tiptap/core';
import { Button, buttonVariants } from '$src/base/button';
const { node, updateAttributes, extension }: NodeViewProps = $props();
import * as DropdownMenu from '$src/base/dropdown-menu';
import ChevronDown from '@lucide/svelte/icons/chevron-down';
import Check from '@lucide/svelte/icons/check';
import Copy from '@lucide/svelte/icons/copy';
let preRef = $state<HTMLPreElement>();
let isCopying = $state(false);
const languages: string[] = extension.options.lowlight.listLanguages().sort();
let defaultLanguage = $state(node.attrs.language);
function copyCode() {
if (!preRef) return;
isCopying = true;
navigator.clipboard.writeText(preRef.innerText);
setTimeout(() => {
isCopying = false;
}, 1000);
}
</script>
<NodeViewWrapper class="code-wrapper" draggable={false} spellcheck={false}>
<div class="code-wrapper-tile" contenteditable="false">
<DropdownMenu.Root>
<DropdownMenu.Trigger
contenteditable="false"
class={buttonVariants({
variant: 'ghost',
size: 'sm',
class: 'text-muted-foreground h-4 rounded px-1 py-2 text-xs'
})}
>{defaultLanguage}
<ChevronDown class="!size-2" />
</DropdownMenu.Trigger>
<DropdownMenu.Content class="h-96 w-40 overflow-auto" contenteditable="false">
{#each languages as language (language)}
<DropdownMenu.Item
contenteditable="false"
data-current={defaultLanguage === language}
class="data-[current=true]:bg-muted"
textValue={language}
onclick={() => {
defaultLanguage = language;
updateAttributes({ language: defaultLanguage });
}}
>
<span>{language}</span>
{#if defaultLanguage === language}
<Check class="ml-auto" />
{/if}
</DropdownMenu.Item>
{/each}
</DropdownMenu.Content>
</DropdownMenu.Root>
<Button variant="ghost" class="text-muted-foreground size-4 p-0" onclick={copyCode}>
{#if isCopying}
<Check class="size-3 text-green-500" />
{:else}
<Copy class="size-3" />
{/if}
</Button>
</div>
<pre bind:this={preRef} draggable={false}>
<NodeViewContent as="code" class={`language-${defaultLanguage}`} {...node.attrs} />
</pre>
</NodeViewWrapper>
@@ -0,0 +1,23 @@
<script lang="ts">
import * as Tooltip from '$src/base/tooltip';
import type { Snippet } from 'svelte';
interface Props {
tooltip: string;
children: Snippet<[]>;
shortCut?: string;
}
const { tooltip, children, shortCut }: Props = $props();
</script>
<Tooltip.Provider delayDuration={100}>
<Tooltip.Root>
<Tooltip.Trigger>
{@render children()}
</Tooltip.Trigger>
<Tooltip.Content>
<span>{tooltip}</span>
<span class="text-muted-foreground">{shortCut}</span>
</Tooltip.Content>
</Tooltip.Root>
</Tooltip.Provider>
@@ -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} class="m-0 w-full" {...node.attrs}> </iframe>
</MediaExtended>
@@ -0,0 +1,22 @@
<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';
import { buttonVariants } from '$src/base/button';
function handleClick() {
const iframUrl = prompt('Please enter the IFrame URL');
if (iframUrl) {
editor.chain().focus().setIframe({ src: iframUrl }).run();
}
}
</script>
<MediaPlaceHolder
class={buttonVariants({ variant: 'secondary', class: 'my-2 w-full justify-start p-6' })}
icon={Audio}
title="Insert an iframe"
onClick={handleClick}
/>
@@ -0,0 +1,19 @@
<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}
class="m-0 object-cover"
/>
</MediaExtended>
@@ -0,0 +1,22 @@
<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';
import { buttonVariants } from '$src/base/button';
function handleClick() {
const imageUrl = prompt('Please enter the image URL');
if (imageUrl) {
editor.chain().focus().setImage({ src: imageUrl }).run();
}
}
</script>
<MediaPlaceHolder
class={buttonVariants({ variant: 'secondary', class: 'my-2 w-full justify-start p-6' })}
icon={Image}
title="Insert an image"
onClick={handleClick}
/>
@@ -0,0 +1,270 @@
<script lang="ts">
import { onDestroy, onMount, type Snippet } from 'svelte';
import { NodeViewWrapper } from 'svelte-tiptap';
import type { NodeViewProps } from '@tiptap/core';
import { cn } from '$src/tools';
import { Button, buttonVariants } from '$src/base/button';
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 EllipsisVertical from '@lucide/svelte/icons/ellipsis-vertical';
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 * as DropdownMenu from '$src/base/dropdown-menu';
import { duplicateContent } from '../../utils';
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 openedMore = $state(false);
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>
<!-- !FIXME: For some wierd reason, the change in `node.attrs.align` is not reactive to NodeViewWrapper
So for now, we'll re-render it when the align changes.
-->
{#key node.attrs.align}
<NodeViewWrapper
id="resizable-container-media"
class={cn(
'relative my-2 flex flex-col rounded-md border-2 border-transparent',
selected ? 'border-muted-foreground' : '',
node.attrs.align === 'left' && 'left-0 -translate-x-0',
node.attrs.align === 'center' && 'left-1/2 -translate-x-1/2',
node.attrs.align === 'right' && 'left-full -translate-x-full'
)}
style={`width: ${node.attrs.width}`}
>
<div class={cn('group relative flex flex-col rounded-md', resizing && '')}>
{@render children()}
{#if editor.isEditable}
<div
role="button"
tabindex="0"
aria-label="Back"
class="absolute inset-y-0 z-20 flex w-[25px] cursor-col-resize items-center justify-start p-2"
style="left: 0px"
onmousedown={(event: MouseEvent) => {
handleResizingPosition(event, 'left');
}}
ontouchstart={(event: TouchEvent) => {
handleTouchStart(event, 'left');
}}
>
<div
class="bg-muted z-20 h-[70px] w-1 rounded-xl border opacity-0 transition-all group-hover:opacity-100"
></div>
</div>
<div
role="button"
tabindex="0"
aria-label="Back"
class="absolute inset-y-0 z-20 flex w-[25px] cursor-col-resize items-center justify-end p-2"
style="right: 0px"
onmousedown={(event: MouseEvent) => {
handleResizingPosition(event, 'right');
}}
ontouchstart={(event: TouchEvent) => {
handleTouchStart(event, 'right');
}}
>
<div
class="bg-muted z-20 h-[70px] w-1 rounded-xl border opacity-0 transition-all group-hover:opacity-100"
></div>
</div>
<div
class={cn(
'bg-background/50 absolute -top-2 left-[calc(50%-3rem)] flex items-center gap-1 rounded border p-1 opacity-0 backdrop-blur-sm transition-opacity',
!resizing && 'group-hover:opacity-100',
openedMore && 'opacity-100'
)}
>
<Button
variant="ghost"
class={cn('size-6 p-0', node.attrs.align === 'left' && 'bg-muted')}
onclick={() => updateAttributes({ align: 'left' })}
title="Align Left"
>
<AlignLeft class="size-4" />
</Button>
<Button
variant="ghost"
class={cn('size-6 p-0', node.attrs.align === 'center' && 'bg-muted')}
onclick={() => updateAttributes({ align: 'center' })}
title="Align Center"
>
<AlignCenter class="size-4" />
</Button>
<Button
variant="ghost"
class={cn('size-6 p-0', node.attrs.align === 'right' && 'bg-muted')}
onclick={() => updateAttributes({ align: 'right' })}
title="Align Right"
>
<AlignRight class="size-4" />
</Button>
<DropdownMenu.Root bind:open={openedMore} onOpenChange={(value) => (openedMore = value)}>
<DropdownMenu.Trigger
class={buttonVariants({ variant: 'ghost', class: 'size-6 p-0' })}
title="More Options"
>
<EllipsisVertical class="size-4" />
</DropdownMenu.Trigger>
<DropdownMenu.Content align="start" alignOffset={-90} class="mt-1 overflow-auto text-sm">
<DropdownMenu.Item
onclick={() => {
if (node.attrs.title === null || node.attrs.title.trim() === '')
updateAttributes({
title: 'Image Caption'
});
}}
>
<Captions class="mr-1 size-4" /> Caption
</DropdownMenu.Item>
<DropdownMenu.Item
onclick={() => {
duplicateContent(editor, node);
}}
>
<CopyIcon class="mr-1 size-4" /> Duplicate
</DropdownMenu.Item>
<DropdownMenu.Item
onclick={() => {
updateAttributes({
width: '100%'
});
}}
>
<Fullscreen class="mr-1 size-4" /> Full Screen
</DropdownMenu.Item>
<DropdownMenu.Item
onclick={() => {
deleteNode();
}}
class="text-destructive"
>
<Trash class="mr-1 size-4" /> Delete
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</div>
{/if}
</div>
</NodeViewWrapper>
{/key}
@@ -0,0 +1,119 @@
<script lang="ts">
import { Button } from '$src/base/button';
import { cn } from '$src/tools';
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="bg-popover flex max-h-80 flex-col gap-1 overflow-auto scroll-smooth rounded border"
>
{#each items as grp, groupIndex (groupIndex)}
<span class="text-muted-foreground p-2 text-xs">{grp.title}</span>
{#each grp.commands as command, commandIndex (commandIndex)}
{@const Icon = command.icon}
{@const isActive = selectedGroupIndex === groupIndex && selectedCommandIndex === commandIndex}
<Button
id={`${groupIndex}-${commandIndex}`}
variant="ghost"
class={cn('h-8 w-full justify-start gap-2 rounded-sm', isActive && 'bg-muted')}
onclick={() => selectItem(groupIndex, commandIndex)}
>
<Icon />
<span>{command.tooltip}</span>
</Button>
{/each}
{/each}
</div>
{/if}
@@ -0,0 +1,27 @@
<script lang="ts">
import { Button } from '$src/base/button';
import type { EdraToolBarCommands } from '../../commands/types';
import type { Editor } from '@tiptap/core';
import EdraToolTip from './EdraToolTip.svelte';
import { cn } from '$src/tools';
interface Props {
editor: Editor;
command: EdraToolBarCommands;
}
const { editor, command }: Props = $props();
</script>
<EdraToolTip tooltip={command.tooltip ?? ''} shortCut={command.shortCut ?? ''}>
<Button
variant="ghost"
size="icon"
class={cn(command.isActive?.(editor) && 'bg-muted')}
onclick={() => command.onClick?.(editor)}
disabled={command.clickable ? !command.clickable(editor) : false}
>
{@const Icon = command.icon}
<Icon />
</Button>
</EdraToolTip>
@@ -0,0 +1,21 @@
<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}
class="m-0 object-cover"
>
<track kind="captions" />
</video>
</MediaExtended>
@@ -0,0 +1,22 @@
<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';
import { buttonVariants } from '$src/base/button';
function handleClick() {
const videoUrl = prompt('Please enter the video URL');
if (videoUrl) {
editor.chain().focus().setVideo(videoUrl).run();
}
}
</script>
<MediaPlaceHolder
class={buttonVariants({ variant: 'secondary', class: 'my-2 w-full justify-start p-6' })}
icon={Video}
title="Insert a video"
onClick={handleClick}
/>
@@ -0,0 +1,57 @@
<script lang="ts">
import * as DropdownMenu from '$src/base/dropdown-menu';
import commands from '../../../commands/toolbar-commands';
import type { Editor } from '@tiptap/core';
import AlignLeft from '@lucide/svelte/icons/align-left';
import EdraToolTip from '../EdraToolTip.svelte';
import ChevronDown from '@lucide/svelte/icons/chevron-down';
import { buttonVariants } from '$src/base/button';
import { cn } from '$src/tools';
interface Props {
editor: Editor;
}
const { editor }: Props = $props();
const alignments = commands['alignment'];
const isActive = $derived.by(() => {
return alignments.find((alignment) => alignment.isActive?.(editor)) !== undefined;
});
const AlignMentIcon = $derived.by(() => {
const a = alignments.find((alignment) => alignment.isActive?.(editor));
if (a) return a.icon;
else return AlignLeft;
});
</script>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<EdraToolTip tooltip="Alignment">
<div
class={buttonVariants({
variant: 'ghost',
size: 'icon',
class: cn('gap-0')
})}
class:bg-muted={isActive}
>
<AlignMentIcon />
<ChevronDown class="text-muted-foreground !size-2" />
</div>
</EdraToolTip>
</DropdownMenu.Trigger>
<DropdownMenu.Content portalProps={{ disabled: true, to: undefined }}>
{#each alignments as alignment (alignment)}
{@const Icon = alignment.icon}
<DropdownMenu.Item onclick={() => alignment.onClick?.(editor)}>
<Icon />
<span>{alignment.tooltip}</span>
<DropdownMenu.Shortcut>
{alignment.shortCut}
</DropdownMenu.Shortcut>
</DropdownMenu.Item>
{/each}
</DropdownMenu.Content>
</DropdownMenu.Root>
@@ -0,0 +1,53 @@
<script lang="ts">
import { Button } from '$src/base/button';
import * as DropdownMenu from '$src/base/dropdown-menu';
import { Editor } from '@tiptap/core';
import ChevronDown from '@lucide/svelte/icons/chevron-down';
import { cn } from '$src/tools';
import EdraToolTip from '../EdraToolTip.svelte';
interface Props {
class?: string;
editor: Editor;
}
const { class: className = '', 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 || '');
const currentLabel = $derived.by(() => {
const l = FONT_SIZE.find((f) => f.value === currentSize);
if (l) return l.label.split(' ')[0];
return 'Medium';
});
</script>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<EdraToolTip tooltip="Font Size">
<Button variant="ghost" class={cn('gap-0.5 !px-2', className)}>
<span>{currentLabel}</span>
<ChevronDown class="text-muted-foreground !size-2" />
</Button>
</EdraToolTip>
</DropdownMenu.Trigger>
<DropdownMenu.Content class="h-fit w-fit" portalProps={{ disabled: true, to: undefined }}>
{#each FONT_SIZE as fontSize (fontSize)}
<DropdownMenu.Item
onclick={() => {
editor.chain().focus().setFontSize(fontSize.value).run();
}}
style={`font-size: ${fontSize.value}`}>{fontSize.label}</DropdownMenu.Item
>
{/each}
</DropdownMenu.Content>
</DropdownMenu.Root>
@@ -0,0 +1,59 @@
<script lang="ts">
import type { Editor } from '@tiptap/core';
import * as DropdownMenu from '$src/base/dropdown-menu';
import Heading from '@lucide/svelte/icons/heading';
import ChevronDown from '@lucide/svelte/icons/chevron-down';
import commands from '../../../commands/toolbar-commands';
import { cn } from '$src/tools';
import EdraToolTip from '../EdraToolTip.svelte';
import Paragraph from '@lucide/svelte/icons/pilcrow';
import { buttonVariants } from '$src/base/button';
interface Props {
editor: Editor;
}
const { editor }: Props = $props();
const headings = commands['headings'];
const isActive = $derived.by(() => {
return headings.find((h) => h.isActive?.(editor)) !== undefined;
});
const HeadingIcon = $derived.by(() => {
const h = headings.find((h) => h.isActive?.(editor));
return h ? h.icon : Heading;
});
</script>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<EdraToolTip tooltip="Headings">
<div
class={buttonVariants({
variant: 'ghost',
size: 'icon',
class: cn('gap-0')
})}
class:bg-muted={isActive}
>
<HeadingIcon />
<ChevronDown class="text-muted-foreground !size-2" />
</div>
</EdraToolTip>
</DropdownMenu.Trigger>
<DropdownMenu.Content portalProps={{ to: undefined, disabled: true }}>
<DropdownMenu.Item onclick={() => editor.chain().focus().setParagraph().run()}>
<Paragraph />
<span>Paragraph</span>
</DropdownMenu.Item>
{#each headings as heading (heading)}
{@const Icon = heading.icon}
<DropdownMenu.Item onclick={() => heading.onClick?.(editor)}>
<Icon />
<span>{heading.tooltip}</span>
</DropdownMenu.Item>
{/each}
</DropdownMenu.Content>
</DropdownMenu.Root>
@@ -0,0 +1,95 @@
<script lang="ts">
import { Button, buttonVariants } from '$src/base/button';
import * as Popover from '$src/base/popover';
import type { Editor } from '@tiptap/core';
import ChevronDown from '@lucide/svelte/icons/chevron-down';
import { cn } from '$src/tools';
import EdraToolTip from '../EdraToolTip.svelte';
interface Props {
class?: string;
editor: Editor;
}
const { class: className = '', 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>
<Popover.Root>
<Popover.Trigger>
<EdraToolTip tooltip="Quick Colors">
<div
class={buttonVariants({
variant: 'ghost',
size: 'icon',
class: cn('gap-0.5', className)
})}
style={`color: ${currentColor}; background-color: ${currentHighlight}50;`}
>
<span>A</span>
<ChevronDown class="text-muted-foreground !size-2" />
</div>
</EdraToolTip>
</Popover.Trigger>
<Popover.Content class="size-fit shadow-lg" portalProps={{ disabled: true, to: undefined }}>
<div class="text-muted-foreground my-2 text-xs">Text Colors</div>
<div class="grid grid-cols-5 gap-2">
{#each colors as color (color)}
<Button
variant="ghost"
class={cn(
`size-6 border-0 p-0 font-normal`,
editor.isActive('textStyle', { color: color.value }) && 'border-2 font-extrabold',
color.value === '' && 'border'
)}
style={`color: ${color.value}; background-color: ${color.value}50; border-color: ${color.value};`}
title={color.label}
onclick={() => {
if (color.value === '' || color.label === 'Default') editor.chain().focus().unsetColor().run();
else
editor
.chain()
.focus()
.setColor(currentColor === color.value ? '' : color.value)
.run();
}}
>
A
</Button>
{/each}
</div>
<div class="text-muted-foreground my-2 text-xs">Highlight Colors</div>
<div class="grid grid-cols-5 gap-2">
{#each colors as color (color)}
<Button
variant="ghost"
class={cn(
`size-6 border-0 p-0 font-normal`,
editor.isActive('highlight', { color: color.value }) && 'border-2',
color.value === '' && 'border'
)}
style={`background-color: ${color.value}50; border-color: ${color.value};`}
title={color.label}
onclick={() => {
if (color.value === '' || color.label === 'Default') editor.chain().focus().unsetHighlight().run();
else editor.chain().focus().toggleHighlight({ color: color.value }).run();
}}>A</Button
>
{/each}
</div>
</Popover.Content>
</Popover.Root>
@@ -0,0 +1,148 @@
<script lang="ts">
import { Button } from '$src/base/button';
import type { Editor } from '@tiptap/core';
import Search from '@lucide/svelte/icons/search';
import * as Popover from '$src/base/popover';
import { Input } from '$src/base/input';
import ArrowLeft from '@lucide/svelte/icons/arrow-left';
import ArrowRight from '@lucide/svelte/icons/arrow-right';
import ChevronDown from '@lucide/svelte/icons/chevron-down';
import ChevronRight from '@lucide/svelte/icons/chevron-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 { cn } from '$src/tools';
import { slide } from 'svelte/transition';
import EdraToolTip from '../EdraToolTip.svelte';
interface Props {
editor: Editor;
}
const { editor }: Props = $props();
let open = $state(false);
let showMore = $state(false);
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>
<Popover.Root
bind:open
onOpenChange={(value) => {
if (value === false) {
clear();
updateSearchTerm();
}
}}
>
<Popover.Trigger>
<EdraToolTip tooltip="Search and Replace">
<Button variant="ghost" size="icon" class="gap-0.5">
<Search />
<ChevronDown class="text-muted-foreground !size-2" />
</Button>
</EdraToolTip>
</Popover.Trigger>
<Popover.Content class="flex w-fit items-center gap-1 p-2" portalProps={{ disabled: true, to: undefined }}>
<Button
variant="ghost"
size="icon"
class={cn('transition-transform', showMore && 'bg-muted rotate-90')}
onclick={() => (showMore = !showMore)}
title="Show More"
>
<ChevronRight />
</Button>
<div class="flex size-full flex-col gap-1">
<div class="flex w-full items-center gap-1">
<Input placeholder="Search..." bind:value={searchText} oninput={() => updateSearchTerm()} class="w-40" />
<span class="text-muted-foreground text-sm">{searchCount > 0 ? searchIndex + 1 : 0}/{searchCount} </span>
<EdraToolTip tooltip="Case Sensitive">
<Button
variant="ghost"
size="icon"
class={cn(caseSensitive && 'bg-muted')}
onclick={() => {
caseSensitive = !caseSensitive;
updateSearchTerm();
}}
>
<CaseSensitive />
</Button>
</EdraToolTip>
<EdraToolTip tooltip="Go to previous">
<Button variant="ghost" size="icon" onclick={previous} title="Previous">
<ArrowLeft />
</Button>
</EdraToolTip>
<EdraToolTip tooltip="Go to next">
<Button variant="ghost" size="icon" onclick={next} title="Next">
<ArrowRight />
</Button>
</EdraToolTip>
</div>
{#if showMore}
<div transition:slide class="flex w-full items-center gap-1">
<Input placeholder="Replace..." bind:value={replaceText} oninput={() => updateSearchTerm()} class="w-40" />
<EdraToolTip tooltip="Replace">
<Button variant="ghost" size="icon" onclick={replace}>
<Replace />
</Button>
</EdraToolTip>
<EdraToolTip tooltip="Replace All">
<Button variant="ghost" size="icon" onclick={replaceAll}>
<ReplaceAll />
</Button>
</EdraToolTip>
</div>
{/if}
</div>
</Popover.Content>
</Popover.Root>
@@ -0,0 +1,103 @@
<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';
import { Button } from '$src/base/button';
import type { Node } from '@tiptap/pm/model';
import * as DropdownMenu from '$src/base/dropdown-menu';
import RemoveFormatting from '@lucide/svelte/icons/remove-formatting';
import Duplicate from '@lucide/svelte/icons/copy';
import Clipboard from '@lucide/svelte/icons/clipboard';
import Delete from '@lucide/svelte/icons/trash-2';
import { NodeSelection } from '@tiptap/pm/state';
interface Props {
editor: Editor;
}
const { editor }: Props = $props();
let currentNode: Node | null = $state(null);
let currentNodePos: number = $state(-1);
let open = $state(false);
const pluginKey = 'globalDragHandle';
onMount(() => {
const plugin = DragHandlePlugin({
pluginKey: pluginKey,
dragHandleWidth: 20,
scrollTreshold: 100,
dragHandleSelector: '.drag-handle',
excludedTags: ['pre', 'code', 'table p'],
customNodes: [],
onMouseMove: onMouseMove
});
editor.registerPlugin(plugin);
return () => editor.unregisterPlugin(pluginKey);
});
const onMouseMove = (data: { node: Node; pos: number }) => {
if (data.node) currentNode = data.node;
currentNodePos = data.pos;
};
const handleRemoveFormatting = () => {
const chain = editor.chain();
chain.setNodeSelection(currentNodePos).unsetAllMarks();
chain.setParagraph();
chain.run();
};
const handleDuplicate = () => {
editor.commands.setNodeSelection(currentNodePos);
const selectedNode = editor.state.selection.$anchor.node(1) || (editor.state.selection as NodeSelection).node;
editor
.chain()
.setMeta('hideDragHandle', true)
.insertContentAt(currentNodePos + (currentNode?.nodeSize || 0), selectedNode.toJSON())
.run();
};
const handleCopyToClipboard = () => {
editor.chain().setMeta('hideDragHandle', true).setNodeSelection(currentNodePos).run();
/**
* !FIXME: document.execCommand is deprecated, use navigator.clipboard.writeText instead
*/
document.execCommand('copy');
};
const handleDelete = () => {
editor.chain().setMeta('hideDragHandle', true).setNodeSelection(currentNodePos).deleteSelection().run();
};
</script>
<div class="drag-handle">
<Button variant="ghost" class="!size-6 rounded-sm p-0" onclick={() => (open = true)}>
<GripVertical />
</Button>
<DropdownMenu.Root bind:open>
<DropdownMenu.Trigger class="sr-only">
<span>Drag Handle</span>
</DropdownMenu.Trigger>
<DropdownMenu.Content>
<DropdownMenu.Item onclick={handleRemoveFormatting}>
<RemoveFormatting />
Remove Formatting
</DropdownMenu.Item>
<DropdownMenu.Item onclick={handleDuplicate}>
<Duplicate />
Duplicate Node
</DropdownMenu.Item>
<DropdownMenu.Item onclick={handleCopyToClipboard}>
<Clipboard />
Copy to clipboard
</DropdownMenu.Item>
<DropdownMenu.Item onclick={handleDelete}>
<Delete class="text-destructive" />
Delete Node
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</div>
@@ -0,0 +1,107 @@
<script lang="ts">
import { onDestroy, onMount } from 'svelte';
import type { EdraEditorProps } from '../types';
import initEditor from '../editor';
import { focusEditor } from '../utils';
import { cn } from '$src/tools';
import { ImagePlaceholder } from '../extensions/image/ImagePlaceholder';
import ImagePlaceholderComp from './components/ImagePlaceholder.svelte';
import { ImageExtended } from '../extensions/image/ImageExtended';
import ImageExtendedComp from './components/ImageExtended.svelte';
import { VideoPlaceholder } from '../extensions/video/VideoPlaceholder';
import VideoPlaceHolderComp from './components/VideoPlaceholder.svelte';
import { VideoExtended } from '../extensions/video/VideoExtended';
import VideoExtendedComp from './components/VideoExtended.svelte';
import { AudioPlaceholder } from '../extensions/audio/AudioPlaceholder';
import { AudioExtended } from '../extensions/audio/AudiExtended';
import AudioPlaceHolderComp from './components/AudioPlaceHolder.svelte';
import AudioExtendedComp from './components/AudioExtended.svelte';
import { IFramePlaceholder } from '../extensions/iframe/IFramePlaceholder';
import { IFrameExtended } from '../extensions/iframe/IFrameExtended';
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';
import SlashCommandList from './components/SlashCommandList.svelte';
import '../editor.css';
import './style.css';
import '../onedark.css';
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) {
editor = undefined;
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={cn('edra-editor h-full w-full cursor-auto px-4 *:outline-none', className)}
></div>
@@ -0,0 +1,4 @@
export { default as EdraEditor } from './editor.svelte';
export { default as EdraToolBar } from './toolbar.svelte';
export { default as EdraBubbleMenu } from './menus/Menu.svelte';
export { default as EdraDragHandleExtended } from './drag-handle-extended.svelte';
@@ -0,0 +1,4 @@
export { default as EdraEditor } from './editor.svelte';
export { default as EdraToolBar } from './toolbar.svelte';
export { default as EdraBubbleMenu } from './menus/Menu.svelte';
export { default as EdraDragHandleExtended } from './drag-handle-extended.svelte';
@@ -0,0 +1,54 @@
<script lang="ts">
import type { ShouldShowProps } from '../../types';
import BubbleMenu from '../../components/BubbleMenu.svelte';
import type { Editor } from '@tiptap/core';
import { Button } from '$src/base/button';
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');
}}
options={{
strategy: 'fixed'
}}
class="bg-popover flex h-fit w-fit items-center gap-1 rounded border p-1 shadow-lg"
>
<Button variant="link" href={link} class="max-w-80 p-1" target="_blank">
<span class="w-full overflow-hidden text-ellipsis">
{link}
</span>
</Button>
<Button
variant="ghost"
title="Copy Link"
size="icon"
class="z-50"
onclick={() => {
navigator.clipboard.writeText(link);
}}
>
<Copy />
</Button>
<Button
variant="ghost"
title="Remove Link"
size="icon"
onclick={() => editor.chain().focus().extendMarkRange('link').unsetLink().run()}
>
<Trash />
</Button>
</BubbleMenu>
@@ -0,0 +1,113 @@
<script lang="ts">
import commands from '../../commands/toolbar-commands';
import BubbleMenu from '../../components/BubbleMenu.svelte';
import type { EdraToolbarProps, ShouldShowProps } from '../../types';
import { cn } from '$src/tools';
import { isTextSelection } from '@tiptap/core';
import Alignment from '../components/toolbar/Alignment.svelte';
import FontSize from '../components/toolbar/FontSize.svelte';
import Headings from '../components/toolbar/Headings.svelte';
import QuickColors from '../components/toolbar/QuickColors.svelte';
import ToolBarIcon from '../components/ToolBarIcon.svelte';
const {
editor,
class: className,
excludedCommands = ['undo-redo', 'media', 'lists', 'table'],
children
}: 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}
pluginKey="link-bubble-menu"
{shouldShow}
class={cn('edra-bubble-menu bg-popover flex h-fit w-fit items-center rounded-lg p-0.5', className)}
>
{#if children}
{@render children()}
{:else}
{#each toolbarCommands.filter((c) => !excludedCommands?.includes(c)) as cmd (cmd)}
{#if cmd === 'headings'}
<Headings {editor} />
{:else if cmd === 'alignment'}
<Alignment {editor} />
{:else}
{@const commandGroup = commands[cmd]}
{#each commandGroup as command (command)}
<ToolBarIcon {editor} {command} />
{/each}
{/if}
{/each}
<FontSize {editor} />
<QuickColors {editor} />
{/if}
</BubbleMenu>
@@ -0,0 +1,53 @@
<script lang="ts">
import type { ShouldShowProps } from '../../types';
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 { Button } from '$src/base/button';
import { isColumnGripSelected } from '../../extensions/table/utils';
import EdraToolTip from '../components/EdraToolTip.svelte';
import BubbleMenu from '../../components/BubbleMenu.svelte';
interface Props {
editor: Editor;
}
const { 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
});
}}
class="bg-background flex h-fit w-fit items-center gap-1 rounded border shadow-lg"
>
<EdraToolTip tooltip="Add Column After">
<Button variant="ghost" size="icon" onclick={() => editor.chain().focus().addColumnAfter().run()}>
<ArrowRightFromLine />
</Button>
</EdraToolTip>
<EdraToolTip tooltip="Add Column Before">
<Button variant="ghost" size="icon" onclick={() => editor.chain().focus().addColumnBefore().run()}>
<ArrowLeftFromLine />
</Button>
</EdraToolTip>
<EdraToolTip tooltip="Delete This Column">
<Button variant="ghost" size="icon" onclick={() => editor.chain().focus().deleteColumn().run()}>
<Trash />
</Button>
</EdraToolTip>
</BubbleMenu>
@@ -0,0 +1,51 @@
<script lang="ts">
import type { ShouldShowProps } from '../../types';
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 { Button } from '$src/base/button';
import { isRowGripSelected } from '../../extensions/table/utils';
import EdraToolTip from '../components/EdraToolTip.svelte';
import BubbleMenu from '../../components/BubbleMenu.svelte';
interface Props {
editor: Editor;
}
const { 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
});
}}
class="bg-background flex h-fit w-fit items-center gap-1 rounded border shadow-lg"
>
<EdraToolTip tooltip="Add Row After">
<Button variant="ghost" size="icon" onclick={() => editor.chain().focus().addRowAfter().run()}>
<ArrowDownFromLine />
</Button>
</EdraToolTip>
<EdraToolTip tooltip="Add Row Before">
<Button variant="ghost" size="icon" onclick={() => editor.chain().focus().addRowBefore().run()}>
<ArrowUpFromLine />
</Button>
</EdraToolTip>
<EdraToolTip tooltip="Delete This Row">
<Button variant="ghost" size="icon" onclick={() => editor.chain().focus().deleteRow().run()}>
<Trash />
</Button>
</EdraToolTip>
</BubbleMenu>
@@ -0,0 +1,25 @@
:root {
--border-color: var(--muted);
--border-color-hover: var(--muted-foreground);
--blockquote-border: var(--border);
--blockquote-color: var(--muted-foreground);
--codeblock-bg: var(--muted);
--code-bg: var(--muted);
--table-border: var(--border);
--table-bg-selected: color-mix(in oklch, var(--muted) 60%, transparent);
--table-bg-hover: color-mix(in oklch, var(--muted) 30%, transparent);
--task-completed-color: var(--muted-foreground);
--highlight-color: rgba(0, 255, 0, 0.1);
--highlight-border: green;
--search-result-bg: yellow;
--search-result-current-bg: orange;
}
.dark {
--codeblock-bg: color-mix(in oklch, var(--muted) 30%, transparent);
}
.tiptap a {
color: oklch(62.3% 0.214 259.815);
text-decoration: underline;
}
@@ -0,0 +1,37 @@
<script lang="ts">
import { cn } from '$src/tools';
import commands from '../commands/toolbar-commands';
import type { EdraToolbarProps } from '../types';
import Alignment from './components/toolbar/Alignment.svelte';
import FontSize from './components/toolbar/FontSize.svelte';
import Headings from './components/toolbar/Headings.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));
</script>
<div class={cn('edra-toolbar', className)}>
{#if children}
{@render children()}
{:else}
{#each toolbarCommands as cmd (cmd)}
{#if cmd === 'headings'}
<Headings {editor} />
{:else if cmd === 'alignment'}
<Alignment {editor} />
{:else}
{@const commandGroup = commands[cmd]}
{#each commandGroup as command (command)}
<ToolBarIcon {editor} {command} />
{/each}
{/if}
{/each}
<FontSize {editor} />
<QuickColors {editor} />
<SearchAndReplace {editor} />
{/if}
</div>
+119
View File
@@ -0,0 +1,119 @@
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 =
typeof window !== 'undefined' && typeof navigator !== 'undefined'
? 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();
}
}