add edra tiptap editor component
copied from edra library with svelte 5 fix for onTransaction callback
This commit is contained in:
parent
96ba26feba
commit
2792279f9a
61 changed files with 6201 additions and 187 deletions
31
package.json
31
package.json
|
|
@ -68,20 +68,41 @@
|
|||
},
|
||||
"packageManager": "pnpm@10.15.1+sha512.34e538c329b5553014ca8e8f4535997f96180a1d0f614339357449935350d924e22f8614682191264ec33d1462ac21561aff97f6bb18065351c162c7e8f6de67",
|
||||
"dependencies": {
|
||||
"@floating-ui/dom": "^1.7.4",
|
||||
"@friendofsvelte/tipex": "^0.0.9",
|
||||
"@internationalized/date": "^3.10.0",
|
||||
"@lucide/svelte": "^0.562.0",
|
||||
"@tanstack/svelte-query": "^6.0.9",
|
||||
"@tiptap/core": "^3.5.1",
|
||||
"@tiptap/extension-highlight": "^3.5.1",
|
||||
"@tiptap/extension-link": "^3.5.1",
|
||||
"@tiptap/pm": "^3.5.1",
|
||||
"@tiptap/starter-kit": "^3.5.1",
|
||||
"@tiptap/core": "^3.14.0",
|
||||
"@tiptap/extension-bubble-menu": "^3.14.0",
|
||||
"@tiptap/extension-code-block-lowlight": "^3.14.0",
|
||||
"@tiptap/extension-floating-menu": "3.14.0",
|
||||
"@tiptap/extension-highlight": "^3.14.0",
|
||||
"@tiptap/extension-image": "^3.14.0",
|
||||
"@tiptap/extension-link": "^3.14.0",
|
||||
"@tiptap/extension-list": "^3.14.0",
|
||||
"@tiptap/extension-mathematics": "^3.14.0",
|
||||
"@tiptap/extension-subscript": "^3.14.0",
|
||||
"@tiptap/extension-superscript": "^3.14.0",
|
||||
"@tiptap/extension-table": "^3.14.0",
|
||||
"@tiptap/extension-text-align": "^3.14.0",
|
||||
"@tiptap/extension-text-style": "^3.14.0",
|
||||
"@tiptap/extension-typography": "^3.14.0",
|
||||
"@tiptap/extensions": "^3.14.0",
|
||||
"@tiptap/markdown": "^3.14.0",
|
||||
"@tiptap/pm": "^3.14.0",
|
||||
"@tiptap/starter-kit": "^3.14.0",
|
||||
"@tiptap/suggestion": "^3.14.0",
|
||||
"bits-ui": "^2.9.6",
|
||||
"fluid-dnd": "^2.6.2",
|
||||
"katex": "^0.16.27",
|
||||
"lowlight": "^3.3.0",
|
||||
"modern-normalize": "^3.0.1",
|
||||
"runed": "^0.31.1",
|
||||
"svelecte": "^5.3.0",
|
||||
"svelte-sonner": "^1.0.7",
|
||||
"svelte-tiptap": "^3.0.1",
|
||||
"tiptap-extension-auto-joiner": "^0.1.3",
|
||||
"wx-grid-data-provider": "^2.2.0",
|
||||
"wx-svelte-grid": "^2.0.0",
|
||||
"zod": "^4.1.5"
|
||||
|
|
|
|||
645
pnpm-lock.yaml
645
pnpm-lock.yaml
File diff suppressed because it is too large
Load diff
1
src/lib/components/edra/commands/index.ts
Normal file
1
src/lib/components/edra/commands/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default as ToolBarCommands } from './toolbar-commands.js';
|
||||
503
src/lib/components/edra/commands/toolbar-commands.ts
Normal file
503
src/lib/components/edra/commands/toolbar-commands.ts
Normal file
|
|
@ -0,0 +1,503 @@
|
|||
import type { EdraToolBarCommands } from './types.js';
|
||||
import { isMac } from '../utils.js';
|
||||
import Undo from '@lucide/svelte/icons/undo-2';
|
||||
import Redo from '@lucide/svelte/icons/redo-2';
|
||||
import Heading1 from '@lucide/svelte/icons/heading-1';
|
||||
import Heading2 from '@lucide/svelte/icons/heading-2';
|
||||
import Heading3 from '@lucide/svelte/icons/heading-3';
|
||||
import Heading4 from '@lucide/svelte/icons/heading-4';
|
||||
import Link from '@lucide/svelte/icons/link-2';
|
||||
import Bold from '@lucide/svelte/icons/bold';
|
||||
import Italic from '@lucide/svelte/icons/italic';
|
||||
import Underline from '@lucide/svelte/icons/underline';
|
||||
import StrikeThrough from '@lucide/svelte/icons/strikethrough';
|
||||
import Quote from '@lucide/svelte/icons/quote';
|
||||
import Code from '@lucide/svelte/icons/code';
|
||||
import Superscript from '@lucide/svelte/icons/superscript';
|
||||
import Subscript from '@lucide/svelte/icons/subscript';
|
||||
import AlignLeft from '@lucide/svelte/icons/align-left';
|
||||
import AlignCenter from '@lucide/svelte/icons/align-center';
|
||||
import AlignRight from '@lucide/svelte/icons/align-right';
|
||||
import AlighJustify from '@lucide/svelte/icons/align-justify';
|
||||
import List from '@lucide/svelte/icons/list';
|
||||
import ListOrdered from '@lucide/svelte/icons/list-ordered';
|
||||
import ListChecks from '@lucide/svelte/icons/list-checks';
|
||||
import Image from '@lucide/svelte/icons/image';
|
||||
import Video from '@lucide/svelte/icons/video';
|
||||
import Audio from '@lucide/svelte/icons/audio-lines';
|
||||
import IFrame from '@lucide/svelte/icons/code-xml';
|
||||
import Table from '@lucide/svelte/icons/table';
|
||||
import Radical from '@lucide/svelte/icons/radical';
|
||||
import SquareRadical from '@lucide/svelte/icons/square-radical';
|
||||
import { isTextSelection } from '@tiptap/core';
|
||||
import Pilcrow from '@lucide/svelte/icons/pilcrow';
|
||||
|
||||
const commands: Record<string, EdraToolBarCommands[]> = {
|
||||
'undo-redo': [
|
||||
{
|
||||
icon: Undo,
|
||||
name: 'undo',
|
||||
tooltip: 'Undo',
|
||||
shortCut: `${isMac ? '⌘' : 'Ctrl+'}Z`,
|
||||
onClick: (editor) => {
|
||||
editor.chain().focus().undo().run();
|
||||
},
|
||||
clickable: (editor) => {
|
||||
return editor.can().undo();
|
||||
}
|
||||
},
|
||||
{
|
||||
icon: Redo,
|
||||
name: 'redo',
|
||||
tooltip: 'Redo',
|
||||
shortCut: `${isMac ? '⌘' : 'Ctrl+'}Y`,
|
||||
onClick: (editor) => {
|
||||
editor.chain().focus().redo().run();
|
||||
},
|
||||
clickable: (editor) => {
|
||||
return editor.can().redo();
|
||||
}
|
||||
}
|
||||
],
|
||||
headings: [
|
||||
{
|
||||
icon: Heading1,
|
||||
name: 'h1',
|
||||
tooltip: 'Heading 1',
|
||||
shortCut: `${isMac ? '⌘⌥' : 'Ctrl+Alt+'}1`,
|
||||
onClick: (editor) => {
|
||||
editor.chain().focus().toggleHeading({ level: 1 }).run();
|
||||
},
|
||||
turnInto: (editor, pos) => {
|
||||
editor.chain().setNodeSelection(pos).setHeading({ level: 1 }).run();
|
||||
},
|
||||
clickable: (editor) => {
|
||||
return editor.can().toggleHeading({ level: 1 });
|
||||
},
|
||||
isActive: (editor) => {
|
||||
return editor.isActive('heading', { level: 1 });
|
||||
}
|
||||
},
|
||||
{
|
||||
icon: Heading2,
|
||||
name: 'h2',
|
||||
tooltip: 'Heading 2',
|
||||
shortCut: `${isMac ? '⌘⌥' : 'Ctrl+Alt+'}2`,
|
||||
onClick: (editor) => {
|
||||
editor.chain().focus().toggleHeading({ level: 2 }).run();
|
||||
},
|
||||
turnInto: (editor, pos) => {
|
||||
editor.chain().setNodeSelection(pos).setHeading({ level: 2 }).run();
|
||||
},
|
||||
clickable: (editor) => {
|
||||
return editor.can().toggleHeading({ level: 2 });
|
||||
},
|
||||
isActive: (editor) => {
|
||||
return editor.isActive('heading', { level: 2 });
|
||||
}
|
||||
},
|
||||
{
|
||||
icon: Heading3,
|
||||
name: 'h3',
|
||||
tooltip: 'Heading 3',
|
||||
shortCut: `${isMac ? '⌘⌥' : 'Ctrl+Alt+'}3`,
|
||||
onClick: (editor) => {
|
||||
editor.chain().focus().toggleHeading({ level: 3 }).run();
|
||||
},
|
||||
turnInto: (editor, pos) => {
|
||||
editor.chain().setNodeSelection(pos).setHeading({ level: 3 }).run();
|
||||
},
|
||||
clickable: (editor) => {
|
||||
return editor.can().toggleHeading({ level: 3 });
|
||||
},
|
||||
isActive: (editor) => {
|
||||
return editor.isActive('heading', { level: 3 });
|
||||
}
|
||||
},
|
||||
{
|
||||
icon: Heading4,
|
||||
name: 'h4',
|
||||
tooltip: 'Heading 4',
|
||||
shortCut: `${isMac ? '⌘⌥' : 'Ctrl+Alt+'}4`,
|
||||
onClick: (editor) => {
|
||||
editor.chain().focus().toggleHeading({ level: 4 }).run();
|
||||
},
|
||||
turnInto: (editor, pos) => {
|
||||
editor.chain().setNodeSelection(pos).setHeading({ level: 4 }).run();
|
||||
},
|
||||
clickable: (editor) => {
|
||||
return editor.can().toggleHeading({ level: 4 });
|
||||
},
|
||||
isActive: (editor) => {
|
||||
return editor.isActive('heading', { level: 4 });
|
||||
}
|
||||
}
|
||||
],
|
||||
'text-formatting': [
|
||||
{
|
||||
icon: Link,
|
||||
name: 'link',
|
||||
tooltip: 'Link',
|
||||
onClick: (editor) => {
|
||||
if (editor.isActive('link')) {
|
||||
editor.chain().focus().unsetLink().run();
|
||||
} else {
|
||||
const url = window.prompt('Enter the URL of the link:');
|
||||
if (url) {
|
||||
editor.chain().focus().toggleLink({ href: url }).run();
|
||||
}
|
||||
}
|
||||
},
|
||||
isActive: (editor) => {
|
||||
return editor.isActive('link');
|
||||
}
|
||||
},
|
||||
{
|
||||
icon: Pilcrow,
|
||||
name: 'paragraph',
|
||||
tooltip: 'Paragraph',
|
||||
shortCut: `${isMac ? '⌘⇧' : 'Ctrl+Shift+'}0`,
|
||||
onClick: (editor) => {
|
||||
editor.chain().focus().setParagraph().run();
|
||||
},
|
||||
turnInto: (editor, pos) => {
|
||||
editor.chain().setNodeSelection(pos).setParagraph().run();
|
||||
},
|
||||
clickable: (editor) => {
|
||||
return editor.can().setParagraph();
|
||||
},
|
||||
isActive: (editor) => {
|
||||
return editor.isActive('paragraph');
|
||||
}
|
||||
},
|
||||
{
|
||||
icon: Bold,
|
||||
name: 'bold',
|
||||
tooltip: 'Bold',
|
||||
shortCut: `${isMac ? '⌘' : 'Ctrl+'}B`,
|
||||
onClick: (editor) => {
|
||||
editor.chain().focus().toggleBold().run();
|
||||
},
|
||||
turnInto: (editor, pos) => {
|
||||
editor.chain().setNodeSelection(pos).setMark('bold').run();
|
||||
},
|
||||
clickable: (editor) => {
|
||||
return editor.can().toggleBold();
|
||||
},
|
||||
isActive: (editor) => {
|
||||
return editor.isActive('bold');
|
||||
}
|
||||
},
|
||||
{
|
||||
icon: Italic,
|
||||
name: 'italic',
|
||||
tooltip: 'Italic',
|
||||
shortCut: `${isMac ? '⌘' : 'Ctrl+'}I`,
|
||||
onClick: (editor) => {
|
||||
editor.chain().focus().toggleItalic().run();
|
||||
},
|
||||
turnInto: (editor, pos) => {
|
||||
editor.chain().setNodeSelection(pos).setMark('italic').run();
|
||||
},
|
||||
clickable: (editor) => {
|
||||
return editor.can().toggleItalic();
|
||||
},
|
||||
isActive: (editor) => {
|
||||
return editor.isActive('italic');
|
||||
}
|
||||
},
|
||||
{
|
||||
icon: Underline,
|
||||
name: 'underline',
|
||||
tooltip: 'Underline',
|
||||
shortCut: `${isMac ? '⌘' : 'Ctrl+'}U`,
|
||||
onClick: (editor) => {
|
||||
editor.chain().focus().toggleUnderline().run();
|
||||
},
|
||||
turnInto: (editor, pos) => {
|
||||
editor.chain().setNodeSelection(pos).setMark('underline').run();
|
||||
},
|
||||
clickable: (editor) => {
|
||||
return editor.can().toggleUnderline();
|
||||
},
|
||||
isActive: (editor) => {
|
||||
return editor.isActive('underline');
|
||||
}
|
||||
},
|
||||
{
|
||||
icon: StrikeThrough,
|
||||
name: 'strikethrough',
|
||||
tooltip: 'Strikethrough',
|
||||
shortCut: `${isMac ? '⌘⇧' : 'Ctrl+Shift+'}S`,
|
||||
onClick: (editor) => {
|
||||
editor.chain().focus().toggleStrike().run();
|
||||
},
|
||||
turnInto: (editor, pos) => {
|
||||
editor.chain().setNodeSelection(pos).setMark('strike').run();
|
||||
},
|
||||
clickable: (editor) => {
|
||||
return editor.can().toggleStrike();
|
||||
},
|
||||
isActive: (editor) => {
|
||||
return editor.isActive('strike');
|
||||
}
|
||||
},
|
||||
{
|
||||
icon: Quote,
|
||||
name: 'blockQuote',
|
||||
tooltip: 'BlockQuote',
|
||||
shortCut: `${isMac ? '⌘⇧' : 'Ctrl+Shift+'}B`,
|
||||
onClick: (editor) => {
|
||||
editor.chain().focus().toggleBlockquote().run();
|
||||
},
|
||||
turnInto: (editor, pos) => {
|
||||
editor.chain().setNodeSelection(pos).toggleBlockquote().run();
|
||||
},
|
||||
clickable: (editor) => {
|
||||
return editor.can().toggleBlockquote();
|
||||
},
|
||||
isActive: (editor) => {
|
||||
return editor.isActive('blockquote');
|
||||
}
|
||||
},
|
||||
{
|
||||
icon: Code,
|
||||
name: 'code',
|
||||
tooltip: 'Inline Code',
|
||||
shortCut: `${isMac ? '⌘' : 'Ctrl+'}E`,
|
||||
onClick: (editor) => {
|
||||
editor.chain().focus().toggleCode().run();
|
||||
},
|
||||
turnInto: (editor, pos) => {
|
||||
editor.chain().setNodeSelection(pos).toggleCodeBlock().run();
|
||||
},
|
||||
clickable: (editor) => {
|
||||
return editor.can().toggleCode();
|
||||
},
|
||||
isActive: (editor) => {
|
||||
return editor.isActive('code');
|
||||
}
|
||||
},
|
||||
{
|
||||
icon: Superscript,
|
||||
name: 'superscript',
|
||||
tooltip: 'Superscript',
|
||||
shortCut: `${isMac ? '⌘' : 'Ctrl+'}.`,
|
||||
onClick: (editor) => {
|
||||
editor.chain().focus().toggleSuperscript().run();
|
||||
},
|
||||
clickable: (editor) => {
|
||||
return editor.can().toggleSuperscript();
|
||||
},
|
||||
isActive: (editor) => {
|
||||
return editor.isActive('superscript');
|
||||
}
|
||||
},
|
||||
{
|
||||
icon: Subscript,
|
||||
name: 'subscript',
|
||||
tooltip: 'Subscript',
|
||||
shortCut: `${isMac ? '⌘' : 'Ctrl+'},`,
|
||||
onClick: (editor) => {
|
||||
editor.chain().focus().toggleSubscript().run();
|
||||
},
|
||||
clickable: (editor) => {
|
||||
return editor.can().toggleSubscript();
|
||||
},
|
||||
isActive: (editor) => {
|
||||
return editor.isActive('subscript');
|
||||
}
|
||||
}
|
||||
],
|
||||
alignment: [
|
||||
{
|
||||
icon: AlignLeft,
|
||||
name: 'align-left',
|
||||
tooltip: 'Align Left',
|
||||
shortCut: `${isMac ? '⌘⇧' : 'Ctrl+Shift+'}L`,
|
||||
onClick: (editor) => {
|
||||
editor.chain().focus().toggleTextAlign('left').run();
|
||||
},
|
||||
clickable: (editor) => {
|
||||
return editor.can().toggleTextAlign('left');
|
||||
},
|
||||
isActive: (editor) => editor.isActive({ textAlign: 'left' })
|
||||
},
|
||||
{
|
||||
icon: AlignCenter,
|
||||
name: 'align-center',
|
||||
tooltip: 'Align Center',
|
||||
shortCut: `${isMac ? '⌘⇧' : 'Ctrl+Shift+'}E`,
|
||||
onClick: (editor) => {
|
||||
editor.chain().focus().toggleTextAlign('center').run();
|
||||
},
|
||||
clickable: (editor) => {
|
||||
return editor.can().toggleTextAlign('center');
|
||||
},
|
||||
isActive: (editor) => editor.isActive({ textAlign: 'center' })
|
||||
},
|
||||
{
|
||||
icon: AlignRight,
|
||||
name: 'align-right',
|
||||
tooltip: 'Align Right',
|
||||
shortCut: `${isMac ? '⌘⇧' : 'Ctrl+Shift+'}R`,
|
||||
onClick: (editor) => {
|
||||
editor.chain().focus().toggleTextAlign('right').run();
|
||||
},
|
||||
clickable: (editor) => {
|
||||
return editor.can().toggleTextAlign('right');
|
||||
},
|
||||
isActive: (editor) => editor.isActive({ textAlign: 'right' })
|
||||
},
|
||||
{
|
||||
icon: AlighJustify,
|
||||
name: 'align-justify',
|
||||
tooltip: 'Align Justify',
|
||||
shortCut: `${isMac ? '⌘⇧' : 'Ctrl+Shift+'}J`,
|
||||
onClick: (editor) => {
|
||||
editor.chain().focus().toggleTextAlign('justify').run();
|
||||
},
|
||||
clickable: (editor) => {
|
||||
return editor.can().toggleTextAlign('justify');
|
||||
},
|
||||
isActive: (editor) => editor.isActive({ textAlign: 'justify' })
|
||||
}
|
||||
],
|
||||
lists: [
|
||||
{
|
||||
icon: List,
|
||||
name: 'bulletList',
|
||||
tooltip: 'Bullet List',
|
||||
shortCut: `${isMac ? '⌘⇧' : 'Ctrl+Shift+'}8`,
|
||||
onClick: (editor) => {
|
||||
editor.chain().focus().toggleBulletList().run();
|
||||
},
|
||||
turnInto: (editor, pos) => {
|
||||
editor.chain().setNodeSelection(pos).toggleBulletList().run();
|
||||
},
|
||||
isActive: (editor) => editor.isActive('bulletList')
|
||||
},
|
||||
{
|
||||
icon: ListOrdered,
|
||||
name: 'orderedList',
|
||||
tooltip: 'Ordered List',
|
||||
shortCut: `${isMac ? '⌘⇧' : 'Ctrl+Shift+'}7`,
|
||||
onClick: (editor) => {
|
||||
editor.chain().focus().toggleOrderedList().run();
|
||||
},
|
||||
turnInto: (editor, pos) => {
|
||||
editor.chain().setNodeSelection(pos).toggleOrderedList().run();
|
||||
},
|
||||
clickable: (editor) => {
|
||||
return editor.can().toggleOrderedList();
|
||||
},
|
||||
isActive: (editor) => {
|
||||
return editor.isActive('orderedList');
|
||||
}
|
||||
},
|
||||
{
|
||||
icon: ListChecks,
|
||||
name: 'taskList',
|
||||
tooltip: 'Task List',
|
||||
shortCut: `${isMac ? '⌘⇧' : 'Ctrl+Shift+'}9`,
|
||||
onClick: (editor) => {
|
||||
editor.chain().focus().toggleTaskList().run();
|
||||
},
|
||||
turnInto: (editor, pos) => {
|
||||
editor.chain().setNodeSelection(pos).toggleTaskList().run();
|
||||
},
|
||||
clickable: (editor) => {
|
||||
return editor.can().toggleTaskList();
|
||||
},
|
||||
isActive: (editor) => {
|
||||
return editor.isActive('taskList');
|
||||
}
|
||||
}
|
||||
],
|
||||
media: [
|
||||
{
|
||||
icon: Image,
|
||||
name: 'image-placeholder',
|
||||
tooltip: 'Image Placeholder',
|
||||
onClick: (editor) => {
|
||||
editor.chain().focus().insertImagePlaceholder().run();
|
||||
},
|
||||
isActive: (editor) => editor.isActive('image-placeholder')
|
||||
},
|
||||
{
|
||||
icon: Video,
|
||||
name: 'video-placeholder',
|
||||
tooltip: 'Video Placeholder',
|
||||
onClick: (editor) => {
|
||||
editor.chain().focus().insertVideoPlaceholder().run();
|
||||
},
|
||||
isActive: (editor) => editor.isActive('video-placeholder')
|
||||
},
|
||||
{
|
||||
icon: Audio,
|
||||
name: 'audio-placeholder',
|
||||
tooltip: 'Audio Placeholder',
|
||||
onClick: (editor) => {
|
||||
editor.chain().focus().insertAudioPlaceholder().run();
|
||||
},
|
||||
isActive: (editor) => editor.isActive('audio-placeholder')
|
||||
},
|
||||
{
|
||||
icon: IFrame,
|
||||
name: 'iframe-placeholder',
|
||||
tooltip: 'IFrame Placeholder',
|
||||
onClick: (editor) => {
|
||||
editor.chain().focus().insertIFramePlaceholder().run();
|
||||
},
|
||||
isActive: (editor) => editor.isActive('iframe-placeholder')
|
||||
}
|
||||
],
|
||||
table: [
|
||||
{
|
||||
icon: Table,
|
||||
name: 'table',
|
||||
tooltip: 'Table',
|
||||
onClick: (editor) => {
|
||||
if (editor.isActive('table')) {
|
||||
const del = confirm('Do you really want to delete this table??');
|
||||
if (del) {
|
||||
editor.chain().focus().deleteTable().run();
|
||||
return;
|
||||
}
|
||||
}
|
||||
editor.chain().focus().insertTable({ cols: 3, rows: 3, withHeaderRow: false }).run();
|
||||
},
|
||||
isActive: (editor) => editor.isActive('table')
|
||||
}
|
||||
],
|
||||
math: [
|
||||
{
|
||||
icon: Radical,
|
||||
name: 'mathematics',
|
||||
tooltip: 'Inline Expression',
|
||||
onClick: (editor) => {
|
||||
let latex = 'a^2 + b^2 = c^2';
|
||||
const chain = editor.chain().focus();
|
||||
if (isTextSelection(editor.view.state.selection)) {
|
||||
const { from, to } = editor.view.state.selection;
|
||||
latex = editor.view.state.doc.textBetween(from, to);
|
||||
chain.deleteRange({ from, to });
|
||||
}
|
||||
chain.insertInlineMath({ latex }).run();
|
||||
},
|
||||
isActive: (editor) => editor.isActive('inlineMath')
|
||||
},
|
||||
{
|
||||
icon: SquareRadical,
|
||||
name: 'mathematics',
|
||||
tooltip: 'Block Expression',
|
||||
onClick: (editor) => {
|
||||
const latex = 'a^2 + b^2 = c^2';
|
||||
editor.chain().focus().insertBlockMath({ latex }).run();
|
||||
},
|
||||
isActive: (editor) => editor.isActive('blockMath')
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
export default commands;
|
||||
13
src/lib/components/edra/commands/types.ts
Normal file
13
src/lib/components/edra/commands/types.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import type { Editor } from '@tiptap/core';
|
||||
import { Icon } from '@lucide/svelte';
|
||||
|
||||
export interface EdraToolBarCommands {
|
||||
name: string;
|
||||
icon: typeof Icon;
|
||||
tooltip?: string;
|
||||
shortCut?: string;
|
||||
onClick?: (editor: Editor) => void;
|
||||
turnInto?: (editor: Editor, pos: number) => void;
|
||||
isActive?: (editor: Editor) => boolean;
|
||||
clickable?: (editor: Editor) => boolean;
|
||||
}
|
||||
67
src/lib/components/edra/components/BubbleMenu.svelte
Normal file
67
src/lib/components/edra/components/BubbleMenu.svelte
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
<script lang="ts">
|
||||
import { onMount, type Snippet } from 'svelte';
|
||||
import { BubbleMenuPlugin, type BubbleMenuPluginProps } from '@tiptap/extension-bubble-menu';
|
||||
import type { Editor } from '@tiptap/core';
|
||||
|
||||
type Optional<T, K extends keyof T> = Pick<Partial<T>, K> & Omit<T, K>;
|
||||
|
||||
interface Props
|
||||
extends Optional<Omit<Optional<BubbleMenuPluginProps, 'pluginKey'>, 'element'>, 'editor'> {
|
||||
editor?: Editor;
|
||||
children?: Snippet<[]>;
|
||||
class?: string;
|
||||
style?: string;
|
||||
pluginKey?: string;
|
||||
updateDelay?: number;
|
||||
resizeDelay?: number;
|
||||
}
|
||||
|
||||
let {
|
||||
editor,
|
||||
shouldShow = null,
|
||||
class: className = '',
|
||||
style = '',
|
||||
children,
|
||||
updateDelay,
|
||||
resizeDelay,
|
||||
pluginKey = 'bubbleMenu',
|
||||
options,
|
||||
...restProps
|
||||
}: Props = $props();
|
||||
|
||||
let element = $state<HTMLElement>();
|
||||
|
||||
onMount(() => {
|
||||
if (!element) return;
|
||||
|
||||
element.style.position = 'absolute';
|
||||
element.style.visibility = 'hidden';
|
||||
|
||||
if (!editor || editor.isDestroyed) {
|
||||
console.warn('BubbleMenu component does not have editor prop or editor is destroyed.');
|
||||
return;
|
||||
}
|
||||
|
||||
const plugin = BubbleMenuPlugin({
|
||||
pluginKey,
|
||||
editor,
|
||||
element,
|
||||
shouldShow,
|
||||
updateDelay,
|
||||
resizeDelay,
|
||||
options
|
||||
});
|
||||
|
||||
editor.registerPlugin(plugin);
|
||||
|
||||
return () => {
|
||||
if (editor && !editor.isDestroyed) {
|
||||
editor.unregisterPlugin(pluginKey);
|
||||
}
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<div bind:this={element} class={`bubble-menu-wrapper ${className}`} {style} {...restProps}>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
31
src/lib/components/edra/components/DragHandle.svelte
Normal file
31
src/lib/components/edra/components/DragHandle.svelte
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
<script lang="ts">
|
||||
import type { Editor } from '@tiptap/core';
|
||||
import { onMount } from 'svelte';
|
||||
import GripVertical from '@lucide/svelte/icons/grip-vertical';
|
||||
import { DragHandlePlugin } from '../extensions/drag-handle/index.js';
|
||||
|
||||
interface Props {
|
||||
editor: Editor;
|
||||
}
|
||||
|
||||
const { editor }: Props = $props();
|
||||
|
||||
const pluginKey = 'globalDragHandle';
|
||||
|
||||
onMount(() => {
|
||||
const plugin = DragHandlePlugin({
|
||||
pluginKey: pluginKey,
|
||||
dragHandleWidth: 20,
|
||||
scrollTreshold: 100,
|
||||
dragHandleSelector: '.drag-handle',
|
||||
excludedTags: ['pre', 'code', 'table p'],
|
||||
customNodes: []
|
||||
});
|
||||
editor.registerPlugin(plugin);
|
||||
return () => editor.unregisterPlugin(pluginKey);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="drag-handle">
|
||||
<GripVertical />
|
||||
</div>
|
||||
30
src/lib/components/edra/components/MediaPlaceHolder.svelte
Normal file
30
src/lib/components/edra/components/MediaPlaceHolder.svelte
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
<script lang="ts">
|
||||
import type { Icon } from '@lucide/svelte';
|
||||
import type { Snippet } from 'svelte';
|
||||
import { NodeViewWrapper } from 'svelte-tiptap';
|
||||
|
||||
interface Props {
|
||||
icon?: typeof Icon;
|
||||
title?: string;
|
||||
onClick?: () => void;
|
||||
class?: string;
|
||||
children?: Snippet<[]>;
|
||||
}
|
||||
|
||||
const { icon, title, onClick, class: className = '', children }: Props = $props();
|
||||
</script>
|
||||
|
||||
<NodeViewWrapper
|
||||
as="div"
|
||||
contenteditable="false"
|
||||
class={`media-placeholder ${className}`}
|
||||
onclick={onClick}
|
||||
style="user-select: none;"
|
||||
>
|
||||
{#if !children && icon && title}
|
||||
{@const Icon = icon}
|
||||
<Icon />
|
||||
<div contenteditable="false">{title}</div>
|
||||
{/if}
|
||||
{@render children?.()}
|
||||
</NodeViewWrapper>
|
||||
493
src/lib/components/edra/editor.css
Normal file
493
src/lib/components/edra/editor.css
Normal file
|
|
@ -0,0 +1,493 @@
|
|||
/* Base TipTap Editor Styles with Light/Dark Theme Support */
|
||||
|
||||
.tiptap :first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
/* For Placeholder */
|
||||
|
||||
.tiptap .is-empty:not(blockquote.is-empty)::before {
|
||||
pointer-events: none;
|
||||
float: left;
|
||||
height: 0;
|
||||
color: var(--color-muted-foreground);
|
||||
content: attr(data-placeholder);
|
||||
}
|
||||
|
||||
/* Heading Styles */
|
||||
.tiptap h1,
|
||||
.tiptap h2,
|
||||
.tiptap h3,
|
||||
.tiptap h4,
|
||||
.tiptap h5,
|
||||
.tiptap h6 {
|
||||
line-height: 1.2;
|
||||
margin-top: 1rem;
|
||||
text-wrap: pretty;
|
||||
}
|
||||
|
||||
.tiptap h1,
|
||||
.tiptap h2 {
|
||||
margin-top: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.tiptap h1 {
|
||||
scroll-margin: 5rem;
|
||||
font-size: 2.25rem;
|
||||
line-height: 2.5rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.015em;
|
||||
}
|
||||
|
||||
.tiptap h2 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.tiptap h3 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tiptap h4,
|
||||
.tiptap h5,
|
||||
.tiptap h6 {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tiptap blockquote {
|
||||
margin: 1rem 0rem;
|
||||
padding: 0.5rem 0;
|
||||
padding-left: 0.75rem;
|
||||
font-style: italic;
|
||||
color: var(--blockquote-color);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.tiptap blockquote::before {
|
||||
content: ' ';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 0.4rem;
|
||||
background-color: var(--blockquote-border);
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.tiptap blockquote p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Horizontal Rule */
|
||||
.tiptap hr {
|
||||
border: none;
|
||||
border-top: 1px solid var(--border-color);
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
/* Inline Code */
|
||||
.tiptap code:not(pre code) {
|
||||
background-color: var(--code-bg);
|
||||
border-radius: 0.25rem;
|
||||
padding: 0.2rem 0.3rem;
|
||||
font-family: monospace;
|
||||
font-size: 0.875rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* List Styling */
|
||||
|
||||
.tiptap ul,
|
||||
.tiptap ol {
|
||||
padding: 0 1rem;
|
||||
margin: 0.5rem 1rem 0.5rem 0.4rem;
|
||||
}
|
||||
|
||||
.tiptap ul li p,
|
||||
.tiptap ol li p {
|
||||
margin-top: 0.25rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
/* Task List Styling */
|
||||
.tiptap ul[data-type='taskList'] {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.tiptap ul[data-type='taskList'] li {
|
||||
align-items: flex-start;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.tiptap ul[data-type='taskList'] li > label {
|
||||
flex: 0 0 auto;
|
||||
margin-right: 0.5rem;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.tiptap ul[data-type='taskList'] li > div {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.tiptap ul[data-type='taskList'] input[type='checkbox'] {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tiptap ul[data-type='taskList'] ul[data-type='taskList'] {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
ul[data-type='taskList'] li[data-checked='true'] div {
|
||||
color: var(--task-completed-color);
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
input[type='checkbox'] {
|
||||
position: relative;
|
||||
top: 0.25rem;
|
||||
margin: 0;
|
||||
display: grid;
|
||||
place-content: center;
|
||||
cursor: pointer;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
/* Color Swatches */
|
||||
.color {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.color::before {
|
||||
margin-bottom: 0.15rem;
|
||||
margin-right: 0.1rem;
|
||||
display: inline-block;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
border-radius: 0.25rem;
|
||||
border: 1px solid var(--border-color);
|
||||
vertical-align: middle;
|
||||
background-color: var(--color);
|
||||
content: ' ';
|
||||
}
|
||||
|
||||
/* Code Block Styling */
|
||||
.tiptap pre {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
height: fit-content;
|
||||
overflow: auto;
|
||||
background-color: transparent;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.tiptap pre code {
|
||||
flex: 1;
|
||||
border-radius: 0 !important;
|
||||
background-color: transparent;
|
||||
padding: 0;
|
||||
color: inherit;
|
||||
font-family: 'JetBrains Mono', monospace, Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono';
|
||||
}
|
||||
|
||||
/* Drag Handle Styling */
|
||||
.drag-handle {
|
||||
position: fixed;
|
||||
z-index: 50;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
display: flex;
|
||||
padding-right: 0.5rem;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
cursor: grab;
|
||||
opacity: 100;
|
||||
transition-property: all;
|
||||
transition-duration: 300ms;
|
||||
transition-timing-function: cubic-bezier(0.4, 0, 1, 1);
|
||||
color: var(--border-color-hover);
|
||||
}
|
||||
|
||||
.drag-handle:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.drag-handle.hide {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 600px) {
|
||||
.drag-handle {
|
||||
display: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.drag-handle svg {
|
||||
height: 1rem;
|
||||
width: 1rem;
|
||||
}
|
||||
|
||||
/* Math Equations (KaTeX) */
|
||||
.katex:hover {
|
||||
background-color: var(--code-bg);
|
||||
}
|
||||
|
||||
.katex.result {
|
||||
border-bottom: 1px dashed var(--highlight-border);
|
||||
background-color: var(--highlight-color);
|
||||
}
|
||||
|
||||
/* Table Styling */
|
||||
.ProseMirror .tableWrapper {
|
||||
margin: 0;
|
||||
overflow: auto;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.ProseMirror table {
|
||||
margin-top: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
border-radius: 0.25rem;
|
||||
border: 1px solid var(--table-border);
|
||||
}
|
||||
|
||||
.ProseMirror table td,
|
||||
.ProseMirror table th {
|
||||
position: relative;
|
||||
min-width: 100px;
|
||||
border: 1px solid var(--table-border);
|
||||
padding: 0.5rem;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.ProseMirror table td:first-of-type:not(a),
|
||||
.ProseMirror table th:first-of-type:not(a) {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.ProseMirror table td p,
|
||||
.ProseMirror table th p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.ProseMirror table td p + p,
|
||||
.ProseMirror table th p + p {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.ProseMirror table th {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.ProseMirror table .column-resize-handle {
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: -0.25rem;
|
||||
bottom: -2px;
|
||||
display: flex;
|
||||
width: 0.5rem;
|
||||
background-color: var(--table-border);
|
||||
}
|
||||
|
||||
.ProseMirror table .column-resize-handle::before {
|
||||
content: '';
|
||||
margin-left: 0.5rem;
|
||||
height: 100%;
|
||||
width: 1px;
|
||||
background-color: var(--table-border);
|
||||
}
|
||||
|
||||
.ProseMirror table .selectedCell {
|
||||
border-style: double;
|
||||
border-color: var(--table-border);
|
||||
background-color: var(--table-bg-selected);
|
||||
}
|
||||
|
||||
.ProseMirror table .grip-column,
|
||||
.ProseMirror table .grip-row {
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
cursor: pointer;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: var(--table-bg-selected);
|
||||
}
|
||||
|
||||
.ProseMirror table .grip-column {
|
||||
top: -0.75rem;
|
||||
left: 0;
|
||||
margin-left: -1px;
|
||||
height: 0.75rem;
|
||||
width: calc(100% + 1px);
|
||||
border-left: 1px solid var(--table-border);
|
||||
}
|
||||
|
||||
.ProseMirror table .grip-column:hover::before,
|
||||
.ProseMirror table .grip-column.selected::before {
|
||||
content: '';
|
||||
width: 0.625rem;
|
||||
}
|
||||
|
||||
.ProseMirror table .grip-column:hover {
|
||||
background-color: var(--table-bg-hover);
|
||||
}
|
||||
|
||||
.ProseMirror table .grip-column:hover::before {
|
||||
border-bottom: 2px dotted var(--border-color-hover);
|
||||
}
|
||||
|
||||
.ProseMirror table .grip-column.first {
|
||||
border-top-left-radius: 0.125rem;
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.ProseMirror table .grip-column.last {
|
||||
border-top-right-radius: 0.125rem;
|
||||
}
|
||||
|
||||
.ProseMirror table .grip-column.selected {
|
||||
border-color: var(--table-border);
|
||||
background-color: var(--table-bg-hover);
|
||||
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.ProseMirror table .grip-column.selected::before {
|
||||
border-bottom: 2px dotted var(--border-color-hover);
|
||||
}
|
||||
|
||||
.ProseMirror table .grip-row {
|
||||
left: -0.75rem;
|
||||
top: 0;
|
||||
margin-top: -1px;
|
||||
height: calc(100% + 1px);
|
||||
width: 0.75rem;
|
||||
border-top: 1px solid var(--table-border);
|
||||
}
|
||||
|
||||
.ProseMirror table .grip-row:hover::before,
|
||||
.ProseMirror table .grip-row.selected::before {
|
||||
content: '';
|
||||
height: 0.625rem;
|
||||
}
|
||||
|
||||
.ProseMirror table .grip-row:hover {
|
||||
background-color: var(--table-bg-hover);
|
||||
}
|
||||
|
||||
.ProseMirror table .grip-row:hover::before {
|
||||
border-left: 2px dotted var(--border-color-hover);
|
||||
}
|
||||
|
||||
.ProseMirror table .grip-row.first {
|
||||
border-top-left-radius: 0.125rem;
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.ProseMirror table .grip-row.last {
|
||||
border-bottom-left-radius: 0.125rem;
|
||||
}
|
||||
|
||||
.ProseMirror table .grip-row.selected {
|
||||
border-color: var(--table-border);
|
||||
background-color: var(--table-bg-hover);
|
||||
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.ProseMirror table .grip-row.selected::before {
|
||||
border-left: 2px dotted var(--border-color-hover);
|
||||
}
|
||||
|
||||
.tiptap .search-result {
|
||||
background-color: var(--search-result-bg);
|
||||
color: black;
|
||||
}
|
||||
|
||||
.tiptap .search-result-current {
|
||||
background-color: var(--search-result-current-bg);
|
||||
color: black;
|
||||
}
|
||||
|
||||
.code-wrapper {
|
||||
background-color: var(--codeblock-bg);
|
||||
border-radius: 0.5rem;
|
||||
padding: 1rem;
|
||||
position: relative;
|
||||
height: fit-content;
|
||||
width: 100%;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.code-wrapper-tile {
|
||||
opacity: 0;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
padding: 0.25rem;
|
||||
right: 0;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
transition: opacity 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.code-wrapper:hover .code-wrapper-tile {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.tiptap iframe {
|
||||
aspect-ratio: 16 / 9;
|
||||
}
|
||||
|
||||
/* Mathematics extension styles */
|
||||
.tiptap .tiptap-mathematics-render {
|
||||
padding: 0 0.25rem;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
/* Editable math block */
|
||||
.tiptap .tiptap-mathematics-render--editable {
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.tiptap .tiptap-mathematics-render--editable:hover {
|
||||
background: var(--code-bg);
|
||||
}
|
||||
|
||||
/* Inline math */
|
||||
.tiptap .tiptap-mathematics-render[data-type='inline-math'] {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
/* Block math */
|
||||
.tiptap .tiptap-mathematics-render[data-type='block-math'] {
|
||||
display: block;
|
||||
margin: 1rem 0;
|
||||
padding: 0.5rem;
|
||||
text-align: center;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
/* Error styles */
|
||||
.tiptap .tiptap-mathematics-render.inline-math-error,
|
||||
.tiptap .tiptap-mathematics-render.block-math-error {
|
||||
background: var(--code-bg);
|
||||
color: var(--color-destructive);
|
||||
border: 1px solid darkred;
|
||||
}
|
||||
130
src/lib/components/edra/editor.ts
Normal file
130
src/lib/components/edra/editor.ts
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
import { Editor, type Extensions, type EditorOptions, type Content } from '@tiptap/core';
|
||||
import StarterKit from '@tiptap/starter-kit';
|
||||
import { getHandlePaste } from './utils.js';
|
||||
import Subscript from '@tiptap/extension-subscript';
|
||||
import Superscript from '@tiptap/extension-superscript';
|
||||
import Typography from '@tiptap/extension-typography';
|
||||
import { ColorHighlighter } from './extensions/ColorHighlighter.js';
|
||||
import { FontSize, TextStyle, Color } from '@tiptap/extension-text-style';
|
||||
import TextAlign from '@tiptap/extension-text-align';
|
||||
import Highlight from '@tiptap/extension-highlight';
|
||||
import SearchAndReplace from './extensions/FindAndReplace.js';
|
||||
import { TaskItem, TaskList } from '@tiptap/extension-list';
|
||||
import { Table, TableCell, TableRow, TableHeader } from './extensions/table/index.js';
|
||||
import { Placeholder } from '@tiptap/extensions';
|
||||
import { Markdown } from '@tiptap/markdown';
|
||||
import MathMatics from '@tiptap/extension-mathematics';
|
||||
|
||||
import AutoJoiner from 'tiptap-extension-auto-joiner';
|
||||
import 'katex/dist/katex.min.css';
|
||||
import { InlineMathReplacer } from './extensions/InlineMathReplacer.js';
|
||||
|
||||
export default (
|
||||
element?: HTMLElement,
|
||||
content?: Content,
|
||||
extensions?: Extensions,
|
||||
options?: Partial<EditorOptions>
|
||||
) => {
|
||||
const editor = new Editor({
|
||||
element,
|
||||
content,
|
||||
extensions: [
|
||||
StarterKit.configure({
|
||||
orderedList: {
|
||||
HTMLAttributes: {
|
||||
class: 'list-decimal'
|
||||
}
|
||||
},
|
||||
bulletList: {
|
||||
HTMLAttributes: {
|
||||
class: 'list-disc'
|
||||
}
|
||||
},
|
||||
heading: {
|
||||
levels: [1, 2, 3, 4]
|
||||
},
|
||||
link: {
|
||||
openOnClick: false,
|
||||
autolink: true,
|
||||
linkOnPaste: true
|
||||
},
|
||||
codeBlock: false
|
||||
}),
|
||||
Highlight.configure({
|
||||
multicolor: true
|
||||
}),
|
||||
Placeholder.configure({
|
||||
emptyEditorClass: 'is-empty',
|
||||
// Use a placeholder:
|
||||
// Use different placeholders depending on the node type:
|
||||
placeholder: ({ node }) => {
|
||||
if (node.type.name === 'heading') {
|
||||
return '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,
|
||||
InlineMathReplacer,
|
||||
MathMatics.configure({
|
||||
blockOptions: {
|
||||
onClick: (node, pos) => {
|
||||
const newCalculation = prompt('Enter new calculation:', node.attrs.latex);
|
||||
if (newCalculation) {
|
||||
editor
|
||||
.chain()
|
||||
.setNodeSelection(pos)
|
||||
.updateBlockMath({ latex: newCalculation })
|
||||
.focus()
|
||||
.run();
|
||||
}
|
||||
}
|
||||
},
|
||||
inlineOptions: {
|
||||
onClick: (node, pos) => {
|
||||
const newCalculation = prompt('Enter new calculation:', node.attrs.latex);
|
||||
if (newCalculation) {
|
||||
editor
|
||||
.chain()
|
||||
.setNodeSelection(pos)
|
||||
.updateInlineMath({ latex: newCalculation })
|
||||
.focus()
|
||||
.run();
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
AutoJoiner,
|
||||
Table,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
TableCell,
|
||||
Markdown,
|
||||
...(extensions ?? [])
|
||||
],
|
||||
...options
|
||||
});
|
||||
|
||||
editor.setOptions({
|
||||
editorProps: {
|
||||
handlePaste: getHandlePaste(editor)
|
||||
}
|
||||
});
|
||||
return editor;
|
||||
};
|
||||
28
src/lib/components/edra/extensions/ColorHighlighter.ts
Normal file
28
src/lib/components/edra/extensions/ColorHighlighter.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { Extension } from '@tiptap/core';
|
||||
import { Plugin } from '@tiptap/pm/state';
|
||||
|
||||
import { findColors } from '../utils.js';
|
||||
|
||||
export const ColorHighlighter = Extension.create({
|
||||
name: 'colorHighlighter',
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
new Plugin({
|
||||
state: {
|
||||
init(_, { doc }) {
|
||||
return findColors(doc);
|
||||
},
|
||||
apply(transaction, oldState) {
|
||||
return transaction.docChanged ? findColors(transaction.doc) : oldState;
|
||||
}
|
||||
},
|
||||
props: {
|
||||
decorations(state) {
|
||||
return this.getState(state);
|
||||
}
|
||||
}
|
||||
})
|
||||
];
|
||||
}
|
||||
});
|
||||
408
src/lib/components/edra/extensions/FindAndReplace.ts
Normal file
408
src/lib/components/edra/extensions/FindAndReplace.ts
Normal file
|
|
@ -0,0 +1,408 @@
|
|||
// MIT License
|
||||
|
||||
// Copyright (c) 2023 - 2024 Jeet Mandaliya (Github Username: sereneinserenade)
|
||||
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
// of this software and associated documentation files (the "Software"), to deal
|
||||
// in the Software without restriction, including without limitation the rights
|
||||
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
// copies of the Software, and to permit persons to whom the Software is
|
||||
// furnished to do so, subject to the following conditions:
|
||||
|
||||
// The above copyright notice and this permission notice shall be included in all
|
||||
// copies or substantial portions of the Software.
|
||||
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
// SOFTWARE.
|
||||
|
||||
import { Extension, type Range, type Dispatch } from '@tiptap/core';
|
||||
import { Decoration, DecorationSet } from '@tiptap/pm/view';
|
||||
import { Plugin, PluginKey, type EditorState, type Transaction } from '@tiptap/pm/state';
|
||||
import { Node as PMNode } from '@tiptap/pm/model';
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
interface Commands<ReturnType> {
|
||||
search: {
|
||||
/**
|
||||
* @description Set search term in extension.
|
||||
*/
|
||||
setSearchTerm: (searchTerm: string) => ReturnType;
|
||||
/**
|
||||
* @description Set replace term in extension.
|
||||
*/
|
||||
setReplaceTerm: (replaceTerm: string) => ReturnType;
|
||||
/**
|
||||
* @description Set case sensitivity in extension.
|
||||
*/
|
||||
setCaseSensitive: (caseSensitive: boolean) => ReturnType;
|
||||
/**
|
||||
* @description Reset current search result to first instance.
|
||||
*/
|
||||
resetIndex: () => ReturnType;
|
||||
/**
|
||||
* @description Find next instance of search result.
|
||||
*/
|
||||
nextSearchResult: () => ReturnType;
|
||||
/**
|
||||
* @description Find previous instance of search result.
|
||||
*/
|
||||
previousSearchResult: () => ReturnType;
|
||||
/**
|
||||
* @description Replace first instance of search result with given replace term.
|
||||
*/
|
||||
replace: () => ReturnType;
|
||||
/**
|
||||
* @description Replace all instances of search result with given replace term.
|
||||
*/
|
||||
replaceAll: () => ReturnType;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
interface TextNodesWithPosition {
|
||||
text: string;
|
||||
pos: number;
|
||||
}
|
||||
|
||||
const getRegex = (s: string, disableRegex: boolean, caseSensitive: boolean): RegExp => {
|
||||
return RegExp(
|
||||
disableRegex ? s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') : s,
|
||||
caseSensitive ? 'gu' : 'gui'
|
||||
);
|
||||
};
|
||||
|
||||
interface ProcessedSearches {
|
||||
decorationsToReturn: DecorationSet;
|
||||
results: Range[];
|
||||
}
|
||||
|
||||
function processSearches(
|
||||
doc: PMNode,
|
||||
searchTerm: RegExp,
|
||||
searchResultClass: string,
|
||||
resultIndex: number
|
||||
): ProcessedSearches {
|
||||
const decorations: Decoration[] = [];
|
||||
const results: Range[] = [];
|
||||
|
||||
let textNodesWithPosition: TextNodesWithPosition[] = [];
|
||||
let index = 0;
|
||||
|
||||
if (!searchTerm) {
|
||||
return {
|
||||
decorationsToReturn: DecorationSet.empty,
|
||||
results: []
|
||||
};
|
||||
}
|
||||
|
||||
doc?.descendants((node, pos) => {
|
||||
if (node.isText) {
|
||||
if (textNodesWithPosition[index]) {
|
||||
textNodesWithPosition[index] = {
|
||||
text: textNodesWithPosition[index].text + node.text,
|
||||
pos: textNodesWithPosition[index].pos
|
||||
};
|
||||
} else {
|
||||
textNodesWithPosition[index] = {
|
||||
text: `${node.text}`,
|
||||
pos
|
||||
};
|
||||
}
|
||||
} else {
|
||||
index += 1;
|
||||
}
|
||||
});
|
||||
|
||||
textNodesWithPosition = textNodesWithPosition.filter(Boolean);
|
||||
|
||||
for (const element of textNodesWithPosition) {
|
||||
const { text, pos } = element;
|
||||
const matches = Array.from(text.matchAll(searchTerm)).filter(([matchText]) => matchText.trim());
|
||||
|
||||
for (const m of matches) {
|
||||
if (m[0] === '') break;
|
||||
|
||||
if (m.index !== undefined) {
|
||||
results.push({
|
||||
from: pos + m.index,
|
||||
to: pos + m.index + m[0].length
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < results.length; i += 1) {
|
||||
const r = results[i];
|
||||
const className =
|
||||
i === resultIndex ? `${searchResultClass} ${searchResultClass}-current` : searchResultClass;
|
||||
const decoration: Decoration = Decoration.inline(r.from, r.to, {
|
||||
class: className
|
||||
});
|
||||
|
||||
decorations.push(decoration);
|
||||
}
|
||||
|
||||
return {
|
||||
decorationsToReturn: DecorationSet.create(doc, decorations),
|
||||
results
|
||||
};
|
||||
}
|
||||
|
||||
const replace = (
|
||||
replaceTerm: string,
|
||||
results: Range[],
|
||||
{ state, dispatch }: { state: EditorState; dispatch: Dispatch }
|
||||
) => {
|
||||
const firstResult = results[0];
|
||||
|
||||
if (!firstResult) return;
|
||||
|
||||
const { from, to } = results[0];
|
||||
|
||||
if (dispatch) dispatch(state.tr.insertText(replaceTerm, from, to));
|
||||
};
|
||||
|
||||
const rebaseNextResult = (
|
||||
replaceTerm: string,
|
||||
index: number,
|
||||
lastOffset: number,
|
||||
results: Range[]
|
||||
): [number, Range[]] | null => {
|
||||
const nextIndex = index + 1;
|
||||
|
||||
if (!results[nextIndex]) return null;
|
||||
|
||||
const { from: currentFrom, to: currentTo } = results[index];
|
||||
|
||||
const offset = currentTo - currentFrom - replaceTerm.length + lastOffset;
|
||||
|
||||
const { from, to } = results[nextIndex];
|
||||
|
||||
results[nextIndex] = {
|
||||
to: to - offset,
|
||||
from: from - offset
|
||||
};
|
||||
|
||||
return [offset, results];
|
||||
};
|
||||
|
||||
const replaceAll = (
|
||||
replaceTerm: string,
|
||||
results: Range[],
|
||||
{ tr, dispatch }: { tr: Transaction; dispatch: Dispatch }
|
||||
) => {
|
||||
let offset = 0;
|
||||
|
||||
let resultsCopy = results.slice();
|
||||
|
||||
if (!resultsCopy.length) return;
|
||||
|
||||
for (let i = 0; i < resultsCopy.length; i += 1) {
|
||||
const { from, to } = resultsCopy[i];
|
||||
|
||||
tr.insertText(replaceTerm, from, to);
|
||||
|
||||
const rebaseNextResultResponse = rebaseNextResult(replaceTerm, i, offset, resultsCopy);
|
||||
|
||||
if (!rebaseNextResultResponse) continue;
|
||||
|
||||
offset = rebaseNextResultResponse[0];
|
||||
resultsCopy = rebaseNextResultResponse[1];
|
||||
}
|
||||
|
||||
if (dispatch) {
|
||||
dispatch(tr);
|
||||
}
|
||||
};
|
||||
|
||||
export const searchAndReplacePluginKey = new PluginKey('searchAndReplacePlugin');
|
||||
|
||||
export interface SearchAndReplaceOptions {
|
||||
searchResultClass: string;
|
||||
disableRegex: boolean;
|
||||
}
|
||||
|
||||
export interface SearchAndReplaceStorage {
|
||||
searchTerm: string;
|
||||
replaceTerm: string;
|
||||
results: Range[];
|
||||
lastSearchTerm: string;
|
||||
caseSensitive: boolean;
|
||||
lastCaseSensitive: boolean;
|
||||
resultIndex: number;
|
||||
lastResultIndex: number;
|
||||
}
|
||||
|
||||
export const SearchAndReplace = Extension.create<SearchAndReplaceOptions, SearchAndReplaceStorage>({
|
||||
name: 'searchAndReplace',
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
searchResultClass: 'search-result',
|
||||
disableRegex: true
|
||||
};
|
||||
},
|
||||
|
||||
addStorage() {
|
||||
return {
|
||||
searchTerm: '',
|
||||
replaceTerm: '',
|
||||
results: [],
|
||||
lastSearchTerm: '',
|
||||
caseSensitive: false,
|
||||
lastCaseSensitive: false,
|
||||
resultIndex: 0,
|
||||
lastResultIndex: 0
|
||||
};
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
setSearchTerm:
|
||||
(searchTerm: string) =>
|
||||
({ editor }) => {
|
||||
editor.storage.searchAndReplace.searchTerm = searchTerm;
|
||||
|
||||
return false;
|
||||
},
|
||||
setReplaceTerm:
|
||||
(replaceTerm: string) =>
|
||||
({ editor }) => {
|
||||
editor.storage.searchAndReplace.replaceTerm = replaceTerm;
|
||||
|
||||
return false;
|
||||
},
|
||||
setCaseSensitive:
|
||||
(caseSensitive: boolean) =>
|
||||
({ editor }) => {
|
||||
editor.storage.searchAndReplace.caseSensitive = caseSensitive;
|
||||
|
||||
return false;
|
||||
},
|
||||
resetIndex:
|
||||
() =>
|
||||
({ editor }) => {
|
||||
editor.storage.searchAndReplace.resultIndex = 0;
|
||||
|
||||
return false;
|
||||
},
|
||||
nextSearchResult:
|
||||
() =>
|
||||
({ editor }) => {
|
||||
const { results, resultIndex } = editor.storage.searchAndReplace;
|
||||
|
||||
const nextIndex = resultIndex + 1;
|
||||
|
||||
if (results[nextIndex]) {
|
||||
editor.storage.searchAndReplace.resultIndex = nextIndex;
|
||||
} else {
|
||||
editor.storage.searchAndReplace.resultIndex = 0;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
previousSearchResult:
|
||||
() =>
|
||||
({ editor }) => {
|
||||
const { results, resultIndex } = editor.storage.searchAndReplace;
|
||||
|
||||
const prevIndex = resultIndex - 1;
|
||||
|
||||
if (results[prevIndex]) {
|
||||
editor.storage.searchAndReplace.resultIndex = prevIndex;
|
||||
} else {
|
||||
editor.storage.searchAndReplace.resultIndex = results.length - 1;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
replace:
|
||||
() =>
|
||||
({ editor, state, dispatch }) => {
|
||||
const { replaceTerm, results } = editor.storage.searchAndReplace;
|
||||
|
||||
replace(replaceTerm, results, { state, dispatch });
|
||||
|
||||
return false;
|
||||
},
|
||||
replaceAll:
|
||||
() =>
|
||||
({ editor, tr, dispatch }) => {
|
||||
const { replaceTerm, results } = editor.storage.searchAndReplace;
|
||||
|
||||
replaceAll(replaceTerm, results, { tr, dispatch });
|
||||
|
||||
return false;
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
const editor = this.editor;
|
||||
const { searchResultClass, disableRegex } = this.options;
|
||||
|
||||
const setLastSearchTerm = (t: string) => (editor.storage.searchAndReplace.lastSearchTerm = t);
|
||||
const setLastCaseSensitive = (t: boolean) =>
|
||||
(editor.storage.searchAndReplace.lastCaseSensitive = t);
|
||||
const setLastResultIndex = (t: number) => (editor.storage.searchAndReplace.lastResultIndex = t);
|
||||
|
||||
return [
|
||||
new Plugin({
|
||||
key: searchAndReplacePluginKey,
|
||||
state: {
|
||||
init: () => DecorationSet.empty,
|
||||
apply({ doc, docChanged }, oldState) {
|
||||
const {
|
||||
searchTerm,
|
||||
lastSearchTerm,
|
||||
caseSensitive,
|
||||
lastCaseSensitive,
|
||||
resultIndex,
|
||||
lastResultIndex
|
||||
} = editor.storage.searchAndReplace;
|
||||
|
||||
if (
|
||||
!docChanged &&
|
||||
lastSearchTerm === searchTerm &&
|
||||
lastCaseSensitive === caseSensitive &&
|
||||
lastResultIndex === resultIndex
|
||||
)
|
||||
return oldState;
|
||||
|
||||
setLastSearchTerm(searchTerm);
|
||||
setLastCaseSensitive(caseSensitive);
|
||||
setLastResultIndex(resultIndex);
|
||||
|
||||
if (!searchTerm) {
|
||||
editor.storage.searchAndReplace.results = [];
|
||||
return DecorationSet.empty;
|
||||
}
|
||||
|
||||
const { decorationsToReturn, results } = processSearches(
|
||||
doc,
|
||||
getRegex(searchTerm, disableRegex, caseSensitive),
|
||||
searchResultClass,
|
||||
resultIndex
|
||||
);
|
||||
|
||||
editor.storage.searchAndReplace.results = results;
|
||||
|
||||
return decorationsToReturn;
|
||||
}
|
||||
},
|
||||
props: {
|
||||
decorations(state) {
|
||||
return this.getState(state);
|
||||
}
|
||||
}
|
||||
})
|
||||
];
|
||||
}
|
||||
});
|
||||
|
||||
export default SearchAndReplace;
|
||||
21
src/lib/components/edra/extensions/InlineMathReplacer.ts
Normal file
21
src/lib/components/edra/extensions/InlineMathReplacer.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { textInputRule } from '@tiptap/core';
|
||||
import { InlineMath } from '@tiptap/extension-mathematics';
|
||||
|
||||
export const InlineMathReplacer = InlineMath.extend({
|
||||
name: 'inlineMathReplacer',
|
||||
addInputRules() {
|
||||
return [
|
||||
textInputRule({
|
||||
find: /\$\$([^$]+)\$\$/,
|
||||
replace: ({ match, commands }) => {
|
||||
const latex = match[1];
|
||||
// Insert the inline math node with the LaTeX content
|
||||
commands.insertInlineMath({
|
||||
latex
|
||||
});
|
||||
return '';
|
||||
}
|
||||
})
|
||||
];
|
||||
}
|
||||
});
|
||||
34
src/lib/components/edra/extensions/audio/AudiExtended.ts
Normal file
34
src/lib/components/edra/extensions/audio/AudiExtended.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import { SvelteNodeViewRenderer } from 'svelte-tiptap';
|
||||
import { Audio } from './AudioExtension.js';
|
||||
import type { NodeViewProps } from '@tiptap/core';
|
||||
import type { Component } from 'svelte';
|
||||
|
||||
export const AudioExtended = (content: Component<NodeViewProps>) =>
|
||||
Audio.extend({
|
||||
addAttributes() {
|
||||
return {
|
||||
src: {
|
||||
default: null
|
||||
},
|
||||
alt: {
|
||||
default: null
|
||||
},
|
||||
title: {
|
||||
default: null
|
||||
},
|
||||
width: {
|
||||
default: '100%'
|
||||
},
|
||||
height: {
|
||||
default: null
|
||||
},
|
||||
align: {
|
||||
default: 'left'
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
addNodeView: () => {
|
||||
return SvelteNodeViewRenderer(content);
|
||||
}
|
||||
});
|
||||
147
src/lib/components/edra/extensions/audio/AudioExtension.ts
Normal file
147
src/lib/components/edra/extensions/audio/AudioExtension.ts
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
import { Node, nodeInputRule } from '@tiptap/core';
|
||||
import { Plugin, PluginKey } from '@tiptap/pm/state';
|
||||
|
||||
export interface AudioOptions {
|
||||
HTMLAttributes: Record<string, unknown>;
|
||||
}
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
interface Commands<ReturnType> {
|
||||
audio: {
|
||||
/**
|
||||
* Set a audio node
|
||||
*/
|
||||
setAudio: (src: string) => ReturnType;
|
||||
/**
|
||||
* Toggle a audio
|
||||
*/
|
||||
toggleAudio: (src: string) => ReturnType;
|
||||
/**
|
||||
* Remove a audio
|
||||
*/
|
||||
removeAudio: () => ReturnType;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const AUDIO_INPUT_REGEX = /!\[(.+|:?)]\((\S+)(?:(?:\s+)["'](\S+)["'])?\)/;
|
||||
|
||||
export const Audio = Node.create<AudioOptions>({
|
||||
name: 'audio',
|
||||
group: 'block',
|
||||
draggable: true,
|
||||
isolating: true,
|
||||
atom: true,
|
||||
addOptions() {
|
||||
return {
|
||||
HTMLAttributes: {}
|
||||
};
|
||||
},
|
||||
addAttributes() {
|
||||
return {
|
||||
src: {
|
||||
default: null,
|
||||
parseHTML: (el) => (el as HTMLSpanElement).getAttribute('src'),
|
||||
renderHTML: (attrs) => ({ src: attrs.src })
|
||||
}
|
||||
};
|
||||
},
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: 'audio',
|
||||
getAttrs: (el) => ({ src: (el as HTMLAudioElement).getAttribute('src') })
|
||||
}
|
||||
];
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return [
|
||||
'audio',
|
||||
{ controls: 'true', style: 'width: 100%;', ...HTMLAttributes },
|
||||
['source', HTMLAttributes]
|
||||
];
|
||||
},
|
||||
addCommands() {
|
||||
return {
|
||||
setAudio:
|
||||
(src: string) =>
|
||||
({ commands }) =>
|
||||
commands.insertContent(
|
||||
`<audio controls autoplay="false" style="width: 100%;" src="${src}"></audio>`
|
||||
),
|
||||
|
||||
toggleAudio:
|
||||
() =>
|
||||
({ commands }) =>
|
||||
commands.toggleNode(this.name, 'paragraph'),
|
||||
removeAudio:
|
||||
() =>
|
||||
({ commands }) =>
|
||||
commands.deleteNode(this.name)
|
||||
};
|
||||
},
|
||||
addInputRules() {
|
||||
return [
|
||||
nodeInputRule({
|
||||
find: AUDIO_INPUT_REGEX,
|
||||
type: this.type,
|
||||
getAttributes: (match) => {
|
||||
const [, , src] = match;
|
||||
|
||||
return { src };
|
||||
}
|
||||
})
|
||||
];
|
||||
},
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
new Plugin({
|
||||
key: new PluginKey('audioDropPlugin'),
|
||||
|
||||
props: {
|
||||
handleDOMEvents: {
|
||||
drop(view, event) {
|
||||
const {
|
||||
state: { schema, tr },
|
||||
dispatch
|
||||
} = view;
|
||||
const hasFiles =
|
||||
event.dataTransfer && event.dataTransfer.files && event.dataTransfer.files.length;
|
||||
|
||||
if (!hasFiles) return false;
|
||||
|
||||
const audios = Array.from(event.dataTransfer.files).filter((file) =>
|
||||
/audio/i.test(file.type)
|
||||
);
|
||||
|
||||
if (audios.length === 0) return false;
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
const coordinates = view.posAtCoords({ left: event.clientX, top: event.clientY });
|
||||
|
||||
audios.forEach((audio) => {
|
||||
const reader = new FileReader();
|
||||
|
||||
reader.onload = (readerEvent) => {
|
||||
const node = schema.nodes.audio.create({ src: readerEvent.target?.result });
|
||||
|
||||
if (coordinates && typeof coordinates.pos === 'number') {
|
||||
const transaction = tr.insert(coordinates?.pos, node);
|
||||
|
||||
dispatch(transaction);
|
||||
}
|
||||
};
|
||||
|
||||
reader.readAsDataURL(audio);
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
];
|
||||
}
|
||||
});
|
||||
64
src/lib/components/edra/extensions/audio/AudioPlaceholder.ts
Normal file
64
src/lib/components/edra/extensions/audio/AudioPlaceholder.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import { Editor, Node, mergeAttributes, type CommandProps, type NodeViewProps } from '@tiptap/core';
|
||||
import type { Component } from 'svelte';
|
||||
import { SvelteNodeViewRenderer } from 'svelte-tiptap';
|
||||
|
||||
export interface AudioPlaceholderOptions {
|
||||
HTMLAttributes: Record<string, object>;
|
||||
onDrop: (files: File[], editor: Editor) => void;
|
||||
onDropRejected?: (files: File[], editor: Editor) => void;
|
||||
onEmbed: (url: string, editor: Editor) => void;
|
||||
allowedMimeTypes?: Record<string, string[]>;
|
||||
maxFiles?: number;
|
||||
maxSize?: number;
|
||||
}
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
interface Commands<ReturnType> {
|
||||
audioPlaceholder: {
|
||||
/**
|
||||
* Inserts an audio placeholder
|
||||
*/
|
||||
insertAudioPlaceholder: () => ReturnType;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const AudioPlaceholder = (
|
||||
component: Component<NodeViewProps>
|
||||
): Node<AudioPlaceholderOptions> =>
|
||||
Node.create<AudioPlaceholderOptions>({
|
||||
name: 'audio-placeholder',
|
||||
addOptions() {
|
||||
return {
|
||||
HTMLAttributes: {},
|
||||
onDrop: () => {},
|
||||
onDropRejected: () => {},
|
||||
onEmbed: () => {}
|
||||
};
|
||||
},
|
||||
parseHTML() {
|
||||
return [{ tag: `div[data-type="${this.name}"]` }];
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return ['div', mergeAttributes(HTMLAttributes)];
|
||||
},
|
||||
group: 'block',
|
||||
draggable: true,
|
||||
atom: true,
|
||||
content: 'inline*',
|
||||
isolating: true,
|
||||
|
||||
addNodeView() {
|
||||
return SvelteNodeViewRenderer(component);
|
||||
},
|
||||
addCommands() {
|
||||
return {
|
||||
insertAudioPlaceholder: () => (props: CommandProps) => {
|
||||
return props.commands.insertContent({
|
||||
type: 'audio-placeholder'
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
});
|
||||
|
|
@ -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.');
|
||||
}
|
||||
381
src/lib/components/edra/extensions/drag-handle/index.ts
Normal file
381
src/lib/components/edra/extensions/drag-handle/index.ts
Normal file
|
|
@ -0,0 +1,381 @@
|
|||
import { Extension } from '@tiptap/core';
|
||||
import { NodeSelection, Plugin, PluginKey, TextSelection } from '@tiptap/pm/state';
|
||||
import { Fragment, Slice, Node } from '@tiptap/pm/model';
|
||||
import { EditorView } from '@tiptap/pm/view';
|
||||
import { serializeForClipboard } from './ClipboardSerializer.js';
|
||||
|
||||
export interface GlobalDragHandleOptions {
|
||||
/**
|
||||
* The width of the drag handle
|
||||
*/
|
||||
dragHandleWidth: number;
|
||||
|
||||
/**
|
||||
* The treshold for scrolling
|
||||
*/
|
||||
scrollTreshold: number;
|
||||
|
||||
/*
|
||||
* The css selector to query for the drag handle. (eg: '.custom-handle').
|
||||
* If handle element is found, that element will be used as drag handle. If not, a default handle will be created
|
||||
*/
|
||||
dragHandleSelector?: string;
|
||||
|
||||
/**
|
||||
* Tags to be excluded for drag handle
|
||||
*/
|
||||
excludedTags: string[];
|
||||
|
||||
/**
|
||||
* Custom nodes to be included for drag handle
|
||||
*/
|
||||
customNodes: string[];
|
||||
|
||||
/**
|
||||
* onNodeChange callback for drag handle
|
||||
* @param data
|
||||
* @returns
|
||||
*/
|
||||
onMouseMove?: (data: { node: Node; pos: number }) => void;
|
||||
}
|
||||
function absoluteRect(node: Element) {
|
||||
const data = node.getBoundingClientRect();
|
||||
const modal = node.closest('[role="dialog"]');
|
||||
|
||||
if (modal && window.getComputedStyle(modal).transform !== 'none') {
|
||||
const modalRect = modal.getBoundingClientRect();
|
||||
|
||||
return {
|
||||
top: data.top - modalRect.top,
|
||||
left: data.left - modalRect.left,
|
||||
width: data.width
|
||||
};
|
||||
}
|
||||
return {
|
||||
top: data.top,
|
||||
left: data.left,
|
||||
width: data.width
|
||||
};
|
||||
}
|
||||
|
||||
function nodeDOMAtCoords(coords: { x: number; y: number }, options: GlobalDragHandleOptions) {
|
||||
const selectors = [
|
||||
'li',
|
||||
'p:not(:first-child)',
|
||||
'pre',
|
||||
'blockquote',
|
||||
'h1',
|
||||
'h2',
|
||||
'h3',
|
||||
'h4',
|
||||
'h5',
|
||||
'h6',
|
||||
...options.customNodes.map((node) => `[data-type=${node}]`)
|
||||
].join(', ');
|
||||
return document
|
||||
.elementsFromPoint(coords.x, coords.y)
|
||||
.find(
|
||||
(elem: Element) => elem.parentElement?.matches?.('.ProseMirror') || elem.matches(selectors)
|
||||
);
|
||||
}
|
||||
function nodePosAtDOM(node: Element, view: EditorView, options: GlobalDragHandleOptions) {
|
||||
const boundingRect = node.getBoundingClientRect();
|
||||
|
||||
return view.posAtCoords({
|
||||
left: boundingRect.left + 50 + options.dragHandleWidth,
|
||||
top: boundingRect.top + 1
|
||||
})?.inside;
|
||||
}
|
||||
|
||||
function calcNodePos(pos: number, view: EditorView) {
|
||||
const $pos = view.state.doc.resolve(pos);
|
||||
if ($pos.depth > 1) return $pos.before($pos.depth);
|
||||
return pos;
|
||||
}
|
||||
|
||||
export function DragHandlePlugin(options: GlobalDragHandleOptions & { pluginKey: string }) {
|
||||
let listType = '';
|
||||
function handleDragStart(event: DragEvent, view: EditorView) {
|
||||
view.focus();
|
||||
|
||||
if (!event.dataTransfer) return;
|
||||
|
||||
const node = nodeDOMAtCoords(
|
||||
{
|
||||
x: event.clientX + 50 + options.dragHandleWidth,
|
||||
y: event.clientY
|
||||
},
|
||||
options
|
||||
);
|
||||
|
||||
if (!(node instanceof Element)) return;
|
||||
|
||||
let draggedNodePos = nodePosAtDOM(node, view, options);
|
||||
if (draggedNodePos == null || draggedNodePos < 0) return;
|
||||
draggedNodePos = calcNodePos(draggedNodePos, view);
|
||||
|
||||
const { from, to } = view.state.selection;
|
||||
const diff = from - to;
|
||||
|
||||
const fromSelectionPos = calcNodePos(from, view);
|
||||
let differentNodeSelected = false;
|
||||
|
||||
const nodePos = view.state.doc.resolve(fromSelectionPos);
|
||||
|
||||
// Check if nodePos points to the top level node
|
||||
if (nodePos.node().type.name === 'doc') differentNodeSelected = true;
|
||||
else {
|
||||
const nodeSelection = NodeSelection.create(view.state.doc, nodePos.before());
|
||||
|
||||
// Check if the node where the drag event started is part of the current selection
|
||||
differentNodeSelected = !(
|
||||
draggedNodePos + 1 >= nodeSelection.$from.pos && draggedNodePos <= nodeSelection.$to.pos
|
||||
);
|
||||
}
|
||||
let selection = view.state.selection;
|
||||
if (!differentNodeSelected && diff !== 0 && !(view.state.selection instanceof NodeSelection)) {
|
||||
const endSelection = NodeSelection.create(view.state.doc, to - 1);
|
||||
selection = TextSelection.create(view.state.doc, draggedNodePos, endSelection.$to.pos);
|
||||
} else {
|
||||
selection = NodeSelection.create(view.state.doc, draggedNodePos);
|
||||
|
||||
// if inline node is selected, e.g mention -> go to the parent node to select the whole node
|
||||
// if table row is selected, go to the parent node to select the whole node
|
||||
if (
|
||||
(selection as NodeSelection).node.type.isInline ||
|
||||
(selection as NodeSelection).node.type.name === 'tableRow'
|
||||
) {
|
||||
const $pos = view.state.doc.resolve(selection.from);
|
||||
selection = NodeSelection.create(view.state.doc, $pos.before());
|
||||
}
|
||||
}
|
||||
view.dispatch(view.state.tr.setSelection(selection));
|
||||
|
||||
// If the selected node is a list item, we need to save the type of the wrapping list e.g. OL or UL
|
||||
if (
|
||||
view.state.selection instanceof NodeSelection &&
|
||||
view.state.selection.node.type.name === 'listItem'
|
||||
) {
|
||||
listType = node.parentElement!.tagName;
|
||||
}
|
||||
|
||||
const slice = view.state.selection.content();
|
||||
const { dom, text } = serializeForClipboard(view, slice);
|
||||
|
||||
event.dataTransfer.clearData();
|
||||
event.dataTransfer.setData('text/html', dom.innerHTML);
|
||||
event.dataTransfer.setData('text/plain', text);
|
||||
event.dataTransfer.effectAllowed = 'copyMove';
|
||||
|
||||
event.dataTransfer.setDragImage(node, 0, 0);
|
||||
|
||||
view.dragging = { slice, move: event.ctrlKey };
|
||||
}
|
||||
|
||||
let dragHandleElement: HTMLElement | null = null;
|
||||
|
||||
function hideDragHandle() {
|
||||
if (dragHandleElement) {
|
||||
dragHandleElement.classList.add('hide');
|
||||
}
|
||||
}
|
||||
|
||||
function showDragHandle() {
|
||||
if (dragHandleElement) {
|
||||
dragHandleElement.classList.remove('hide');
|
||||
}
|
||||
}
|
||||
|
||||
function hideHandleOnEditorOut(event: MouseEvent) {
|
||||
if (event.target instanceof Element) {
|
||||
// Check if the relatedTarget class is still inside the editor
|
||||
const relatedTarget = event.relatedTarget as HTMLElement;
|
||||
const isInsideEditor =
|
||||
relatedTarget?.classList.contains('tiptap') ||
|
||||
relatedTarget?.classList.contains('drag-handle');
|
||||
|
||||
if (isInsideEditor) return;
|
||||
}
|
||||
hideDragHandle();
|
||||
}
|
||||
|
||||
return new Plugin({
|
||||
key: new PluginKey(options.pluginKey),
|
||||
view: (view) => {
|
||||
const handleBySelector = options.dragHandleSelector
|
||||
? document.querySelector<HTMLElement>(options.dragHandleSelector)
|
||||
: null;
|
||||
dragHandleElement = handleBySelector ?? document.createElement('div');
|
||||
dragHandleElement.draggable = true;
|
||||
dragHandleElement.dataset.dragHandle = '';
|
||||
dragHandleElement.classList.add('drag-handle');
|
||||
|
||||
function onDragHandleDragStart(e: DragEvent) {
|
||||
handleDragStart(e, view);
|
||||
}
|
||||
|
||||
dragHandleElement.addEventListener('dragstart', onDragHandleDragStart);
|
||||
|
||||
function onDragHandleDrag(e: DragEvent) {
|
||||
hideDragHandle();
|
||||
const scrollY = window.scrollY;
|
||||
if (e.clientY < options.scrollTreshold) {
|
||||
window.scrollTo({ top: scrollY - 30, behavior: 'smooth' });
|
||||
} else if (window.innerHeight - e.clientY < options.scrollTreshold) {
|
||||
window.scrollTo({ top: scrollY + 30, behavior: 'smooth' });
|
||||
}
|
||||
}
|
||||
|
||||
dragHandleElement.addEventListener('drag', onDragHandleDrag);
|
||||
|
||||
hideDragHandle();
|
||||
|
||||
if (!handleBySelector) {
|
||||
view?.dom?.parentElement?.appendChild(dragHandleElement);
|
||||
}
|
||||
view?.dom?.parentElement?.addEventListener('mouseout', hideHandleOnEditorOut);
|
||||
|
||||
return {
|
||||
destroy: () => {
|
||||
if (!handleBySelector) {
|
||||
dragHandleElement?.remove?.();
|
||||
}
|
||||
dragHandleElement?.removeEventListener('drag', onDragHandleDrag);
|
||||
dragHandleElement?.removeEventListener('dragstart', onDragHandleDragStart);
|
||||
dragHandleElement = null;
|
||||
view?.dom?.parentElement?.removeEventListener('mouseout', hideHandleOnEditorOut);
|
||||
}
|
||||
};
|
||||
},
|
||||
props: {
|
||||
handleDOMEvents: {
|
||||
mousemove: (view, event) => {
|
||||
if (!view.editable) {
|
||||
return;
|
||||
}
|
||||
|
||||
const node = nodeDOMAtCoords(
|
||||
{
|
||||
x: event.clientX + 50 + options.dragHandleWidth,
|
||||
y: event.clientY
|
||||
},
|
||||
options
|
||||
);
|
||||
|
||||
const notDragging = node?.closest('.not-draggable');
|
||||
const excludedTagList = options.excludedTags.concat(['ol', 'ul']).join(', ');
|
||||
|
||||
if (!(node instanceof Element) || node.matches(excludedTagList) || notDragging) {
|
||||
hideDragHandle();
|
||||
return;
|
||||
}
|
||||
|
||||
const nodePos = nodePosAtDOM(node, view, options);
|
||||
if (nodePos !== undefined) {
|
||||
const currentNode = view.state.doc.nodeAt(nodePos);
|
||||
if (currentNode !== null) {
|
||||
options.onMouseMove?.({ node: currentNode, pos: nodePos });
|
||||
}
|
||||
}
|
||||
|
||||
const compStyle = window.getComputedStyle(node);
|
||||
const parsedLineHeight = parseInt(compStyle.lineHeight, 10);
|
||||
const lineHeight = isNaN(parsedLineHeight)
|
||||
? parseInt(compStyle.fontSize) * 1.2
|
||||
: parsedLineHeight;
|
||||
const paddingTop = parseInt(compStyle.paddingTop, 10);
|
||||
|
||||
const rect = absoluteRect(node);
|
||||
|
||||
rect.top += (lineHeight - 24) / 2;
|
||||
rect.top += paddingTop;
|
||||
// Li markers
|
||||
if (node.matches('ul:not([data-type=taskList]) li, ol li')) {
|
||||
rect.left -= options.dragHandleWidth;
|
||||
}
|
||||
rect.width = options.dragHandleWidth;
|
||||
|
||||
if (!dragHandleElement) return;
|
||||
|
||||
dragHandleElement.style.left = `${rect.left - rect.width}px`;
|
||||
dragHandleElement.style.top = `${rect.top}px`;
|
||||
showDragHandle();
|
||||
},
|
||||
keydown: () => {
|
||||
hideDragHandle();
|
||||
},
|
||||
mousewheel: () => {
|
||||
hideDragHandle();
|
||||
},
|
||||
// dragging class is used for CSS
|
||||
dragstart: (view) => {
|
||||
view.dom.classList.add('dragging');
|
||||
},
|
||||
drop: (view, event) => {
|
||||
view.dom.classList.remove('dragging');
|
||||
hideDragHandle();
|
||||
let droppedNode: Node | null = null;
|
||||
const dropPos = view.posAtCoords({
|
||||
left: event.clientX,
|
||||
top: event.clientY
|
||||
});
|
||||
|
||||
if (!dropPos) return;
|
||||
|
||||
if (view.state.selection instanceof NodeSelection) {
|
||||
droppedNode = view.state.selection.node;
|
||||
}
|
||||
if (!droppedNode) return;
|
||||
|
||||
const resolvedPos = view.state.doc.resolve(dropPos.pos);
|
||||
|
||||
const isDroppedInsideList = resolvedPos.parent.type.name === 'listItem';
|
||||
|
||||
// If the selected node is a list item and is not dropped inside a list, we need to wrap it inside <ol> tag otherwise ol list items will be transformed into ul list item when dropped
|
||||
if (
|
||||
view.state.selection instanceof NodeSelection &&
|
||||
view.state.selection.node.type.name === 'listItem' &&
|
||||
!isDroppedInsideList &&
|
||||
listType == 'OL'
|
||||
) {
|
||||
const newList = view.state.schema.nodes.orderedList?.createAndFill(null, droppedNode);
|
||||
const slice = new Slice(Fragment.from(newList), 0, 0);
|
||||
view.dragging = { slice, move: event.ctrlKey };
|
||||
}
|
||||
},
|
||||
dragend: (view) => {
|
||||
view.dom.classList.remove('dragging');
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const GlobalDragHandle = Extension.create({
|
||||
name: 'globalDragHandle',
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
dragHandleWidth: 20,
|
||||
scrollTreshold: 100,
|
||||
excludedTags: [],
|
||||
customNodes: []
|
||||
};
|
||||
},
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
DragHandlePlugin({
|
||||
pluginKey: 'globalDragHandle',
|
||||
dragHandleWidth: this.options.dragHandleWidth,
|
||||
scrollTreshold: this.options.scrollTreshold,
|
||||
dragHandleSelector: this.options.dragHandleSelector,
|
||||
excludedTags: this.options.excludedTags,
|
||||
customNodes: this.options.customNodes,
|
||||
onMouseMove: this.options.onMouseMove
|
||||
})
|
||||
];
|
||||
}
|
||||
});
|
||||
|
||||
export default GlobalDragHandle;
|
||||
85
src/lib/components/edra/extensions/iframe/IFrame.ts
Normal file
85
src/lib/components/edra/extensions/iframe/IFrame.ts
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
import { Node } from '@tiptap/core';
|
||||
|
||||
export interface IframeOptions {
|
||||
allowFullscreen: boolean;
|
||||
HTMLAttributes: {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
interface Commands<ReturnType> {
|
||||
iframe: {
|
||||
/**
|
||||
* Add an iframe with src
|
||||
*/
|
||||
setIframe: (options: { src: string }) => ReturnType;
|
||||
removeIframe: () => ReturnType;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default Node.create<IframeOptions>({
|
||||
name: 'iframe',
|
||||
|
||||
group: 'block',
|
||||
|
||||
atom: true,
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
allowFullscreen: true,
|
||||
HTMLAttributes: {
|
||||
class: 'iframe-wrapper'
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
src: {
|
||||
default: null
|
||||
},
|
||||
frameborder: {
|
||||
default: 0
|
||||
},
|
||||
allowfullscreen: {
|
||||
default: this.options.allowFullscreen,
|
||||
parseHTML: () => this.options.allowFullscreen
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: 'iframe'
|
||||
}
|
||||
];
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return ['div', this.options.HTMLAttributes, ['iframe', HTMLAttributes]];
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
setIframe:
|
||||
(options: { src: string }) =>
|
||||
({ tr, dispatch }) => {
|
||||
const { selection } = tr;
|
||||
const node = this.type.create(options);
|
||||
|
||||
if (dispatch) {
|
||||
tr.replaceRangeWith(selection.from, selection.to, node);
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
removeIframe:
|
||||
() =>
|
||||
({ commands }) =>
|
||||
commands.deleteNode(this.name)
|
||||
};
|
||||
}
|
||||
});
|
||||
35
src/lib/components/edra/extensions/iframe/IFrameExtended.ts
Normal file
35
src/lib/components/edra/extensions/iframe/IFrameExtended.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { SvelteNodeViewRenderer } from 'svelte-tiptap';
|
||||
|
||||
import type { NodeViewProps } from '@tiptap/core';
|
||||
import type { Component } from 'svelte';
|
||||
import IFrame from './IFrame.js';
|
||||
|
||||
export const IFrameExtended = (content: Component<NodeViewProps>) =>
|
||||
IFrame.extend({
|
||||
addAttributes() {
|
||||
return {
|
||||
src: {
|
||||
default: null
|
||||
},
|
||||
alt: {
|
||||
default: null
|
||||
},
|
||||
title: {
|
||||
default: null
|
||||
},
|
||||
width: {
|
||||
default: '100%'
|
||||
},
|
||||
height: {
|
||||
default: null
|
||||
},
|
||||
align: {
|
||||
default: 'left'
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
addNodeView: () => {
|
||||
return SvelteNodeViewRenderer(content);
|
||||
}
|
||||
});
|
||||
|
|
@ -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'
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
});
|
||||
36
src/lib/components/edra/extensions/image/ImageExtended.ts
Normal file
36
src/lib/components/edra/extensions/image/ImageExtended.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import { SvelteNodeViewRenderer } from 'svelte-tiptap';
|
||||
import Image, { type ImageOptions } from '@tiptap/extension-image';
|
||||
import type { Component } from 'svelte';
|
||||
import type { NodeViewProps, Node } from '@tiptap/core';
|
||||
|
||||
export const ImageExtended = (component: Component<NodeViewProps>): Node<ImageOptions, unknown> => {
|
||||
return Image.extend({
|
||||
addAttributes() {
|
||||
return {
|
||||
src: {
|
||||
default: null
|
||||
},
|
||||
alt: {
|
||||
default: null
|
||||
},
|
||||
title: {
|
||||
default: null
|
||||
},
|
||||
width: {
|
||||
default: '100%'
|
||||
},
|
||||
height: {
|
||||
default: null
|
||||
},
|
||||
align: {
|
||||
default: 'left'
|
||||
}
|
||||
};
|
||||
},
|
||||
addNodeView: () => {
|
||||
return SvelteNodeViewRenderer(component);
|
||||
}
|
||||
}).configure({
|
||||
allowBase64: true
|
||||
});
|
||||
};
|
||||
64
src/lib/components/edra/extensions/image/ImagePlaceholder.ts
Normal file
64
src/lib/components/edra/extensions/image/ImagePlaceholder.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import { Editor, Node, mergeAttributes, type CommandProps, type NodeViewProps } from '@tiptap/core';
|
||||
import type { Component } from 'svelte';
|
||||
import { SvelteNodeViewRenderer } from 'svelte-tiptap';
|
||||
|
||||
export interface ImagePlaceholderOptions {
|
||||
HTMLAttributes: Record<string, object>;
|
||||
onDrop: (files: File[], editor: Editor) => void;
|
||||
onDropRejected?: (files: File[], editor: Editor) => void;
|
||||
onEmbed: (url: string, editor: Editor) => void;
|
||||
allowedMimeTypes?: Record<string, string[]>;
|
||||
maxFiles?: number;
|
||||
maxSize?: number;
|
||||
}
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
interface Commands<ReturnType> {
|
||||
imagePlaceholder: {
|
||||
/**
|
||||
* Inserts an image placeholder
|
||||
*/
|
||||
insertImagePlaceholder: () => ReturnType;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const ImagePlaceholder = (
|
||||
component: Component<NodeViewProps>
|
||||
): Node<ImagePlaceholderOptions> =>
|
||||
Node.create<ImagePlaceholderOptions>({
|
||||
name: 'image-placeholder',
|
||||
addOptions() {
|
||||
return {
|
||||
HTMLAttributes: {},
|
||||
onDrop: () => {},
|
||||
onDropRejected: () => {},
|
||||
onEmbed: () => {}
|
||||
};
|
||||
},
|
||||
parseHTML() {
|
||||
return [{ tag: `div[data-type="${this.name}"]` }];
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return ['div', mergeAttributes(HTMLAttributes)];
|
||||
},
|
||||
group: 'block',
|
||||
draggable: true,
|
||||
atom: true,
|
||||
content: 'inline*',
|
||||
isolating: true,
|
||||
|
||||
addNodeView() {
|
||||
return SvelteNodeViewRenderer(component);
|
||||
},
|
||||
addCommands() {
|
||||
return {
|
||||
insertImagePlaceholder: () => (props: CommandProps) => {
|
||||
return props.commands.insertContent({
|
||||
type: 'image-placeholder'
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
});
|
||||
59
src/lib/components/edra/extensions/slash-command/groups.ts
Normal file
59
src/lib/components/edra/extensions/slash-command/groups.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import commands from '../../commands/toolbar-commands.js';
|
||||
|
||||
import type { EdraToolBarCommands } from '../../commands/types.js';
|
||||
import type { Editor } from '@tiptap/core';
|
||||
import Quote from '@lucide/svelte/icons/quote';
|
||||
import SquareCode from '@lucide/svelte/icons/square-code';
|
||||
import Minus from '@lucide/svelte/icons/minus';
|
||||
|
||||
export interface Group {
|
||||
name: string;
|
||||
title: string;
|
||||
actions: EdraToolBarCommands[];
|
||||
}
|
||||
|
||||
export const GROUPS: Group[] = [
|
||||
{
|
||||
name: 'format',
|
||||
title: 'Format',
|
||||
actions: [
|
||||
...commands.headings,
|
||||
{
|
||||
icon: Quote,
|
||||
name: 'blockquote',
|
||||
tooltip: 'Blockquote',
|
||||
onClick: (editor: Editor) => {
|
||||
editor.chain().focus().setBlockquote().run();
|
||||
}
|
||||
},
|
||||
{
|
||||
icon: SquareCode,
|
||||
name: 'codeBlock',
|
||||
tooltip: 'Code Block',
|
||||
onClick: (editor: Editor) => {
|
||||
editor.chain().focus().setCodeBlock().run();
|
||||
}
|
||||
},
|
||||
...commands.lists
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'insert',
|
||||
title: 'Insert',
|
||||
actions: [
|
||||
...commands.media,
|
||||
...commands.table,
|
||||
...commands.math,
|
||||
{
|
||||
icon: Minus,
|
||||
name: 'horizontalRule',
|
||||
tooltip: 'Horizontal Rule',
|
||||
onClick: (editor: Editor) => {
|
||||
editor.chain().focus().setHorizontalRule().run();
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
export default GROUPS;
|
||||
259
src/lib/components/edra/extensions/slash-command/slashcommand.ts
Normal file
259
src/lib/components/edra/extensions/slash-command/slashcommand.ts
Normal file
|
|
@ -0,0 +1,259 @@
|
|||
import { Editor, Extension } from '@tiptap/core';
|
||||
import Suggestion, { type SuggestionProps, type SuggestionKeyDownProps } from '@tiptap/suggestion';
|
||||
import { PluginKey } from '@tiptap/pm/state';
|
||||
import { computePosition, flip, offset, autoUpdate, type Placement } from '@floating-ui/dom';
|
||||
|
||||
import { GROUPS } from './groups.js';
|
||||
import SvelteRenderer from '../../svelte-renderer.js';
|
||||
|
||||
import type { Component } from 'svelte';
|
||||
|
||||
const extensionName = 'slashCommand';
|
||||
|
||||
interface PopupState {
|
||||
element: HTMLElement | null;
|
||||
cleanup: (() => void) | null;
|
||||
isVisible: boolean;
|
||||
}
|
||||
|
||||
const popup: PopupState = {
|
||||
element: null,
|
||||
cleanup: null,
|
||||
isVisible: false
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export default (menuList: Component<any, any, ''>): Extension =>
|
||||
Extension.create({
|
||||
name: extensionName,
|
||||
|
||||
priority: 200,
|
||||
|
||||
onCreate() {
|
||||
// Create popup container
|
||||
popup.element = document.createElement('div');
|
||||
popup.element.style.position = 'fixed';
|
||||
popup.element.style.zIndex = '9999';
|
||||
popup.element.style.maxWidth = '16rem';
|
||||
popup.element.style.visibility = 'hidden';
|
||||
popup.element.style.pointerEvents = 'none';
|
||||
popup.element.className = 'slash-command-popup';
|
||||
document.body.appendChild(popup.element);
|
||||
},
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
Suggestion({
|
||||
editor: this.editor,
|
||||
char: '/',
|
||||
allowSpaces: true,
|
||||
pluginKey: new PluginKey(extensionName),
|
||||
allow: ({ state, range }) => {
|
||||
const $from = state.doc.resolve(range.from);
|
||||
const afterContent = $from.parent.textContent?.substring(
|
||||
$from.parent.textContent?.indexOf('/')
|
||||
);
|
||||
const isValidAfterContent = !afterContent?.endsWith(' ');
|
||||
|
||||
return isValidAfterContent;
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
command: ({ editor, props }: { editor: Editor; props: any }) => {
|
||||
const { view, state } = editor;
|
||||
const { $head, $from } = view.state.selection;
|
||||
|
||||
try {
|
||||
const end = $from.pos;
|
||||
const from = $head?.nodeBefore
|
||||
? end -
|
||||
($head.nodeBefore.text?.substring($head.nodeBefore.text?.indexOf('/')).length ??
|
||||
0)
|
||||
: $from.start();
|
||||
|
||||
const tr = state.tr.deleteRange(from, end);
|
||||
view.dispatch(tr);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
props.onClick(editor);
|
||||
view.focus();
|
||||
},
|
||||
items: ({ query }: { query: string }) => {
|
||||
const withFilteredCommands = GROUPS.map((group) => ({
|
||||
...group,
|
||||
commands: group.actions.filter((item) => {
|
||||
const labelNormalized = item.tooltip!.toLowerCase().trim();
|
||||
const queryNormalized = query.toLowerCase().trim();
|
||||
return labelNormalized.includes(queryNormalized);
|
||||
})
|
||||
}));
|
||||
|
||||
const withoutEmptyGroups = withFilteredCommands.filter((group) => {
|
||||
if (group.commands.length > 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
const withEnabledSettings = withoutEmptyGroups.map((group) => ({
|
||||
...group,
|
||||
commands: group.commands.map((command) => ({
|
||||
...command,
|
||||
isEnabled: true
|
||||
}))
|
||||
}));
|
||||
|
||||
return withEnabledSettings;
|
||||
},
|
||||
render: () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let component: any;
|
||||
|
||||
let scrollHandler: (() => void) | null = null;
|
||||
|
||||
return {
|
||||
onStart: (props: SuggestionProps) => {
|
||||
component = new SvelteRenderer(menuList, {
|
||||
props,
|
||||
editor: props.editor
|
||||
});
|
||||
|
||||
const { view } = props.editor;
|
||||
|
||||
if (popup.element) {
|
||||
popup.element.appendChild(component.element);
|
||||
popup.element.style.visibility = 'visible';
|
||||
popup.element.style.pointerEvents = 'auto';
|
||||
popup.isVisible = true;
|
||||
|
||||
const updatePosition = () => {
|
||||
if (!popup.element || !props.clientRect) return;
|
||||
|
||||
const rect = props.clientRect();
|
||||
if (!rect) return;
|
||||
|
||||
const referenceElement = {
|
||||
getBoundingClientRect: () => rect
|
||||
};
|
||||
|
||||
computePosition(referenceElement, popup.element, {
|
||||
placement: 'bottom-start' as Placement,
|
||||
middleware: [
|
||||
offset({ mainAxis: 8, crossAxis: 16 }),
|
||||
flip({ fallbackPlacements: ['top-start', 'bottom-start'] })
|
||||
]
|
||||
}).then(({ x, y }) => {
|
||||
if (popup.element) {
|
||||
popup.element.style.left = `${x}px`;
|
||||
popup.element.style.top = `${y}px`;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
updatePosition();
|
||||
|
||||
// Set up auto-update for scroll events
|
||||
if (props.clientRect) {
|
||||
const referenceElement = {
|
||||
getBoundingClientRect: () => props.clientRect?.() || new DOMRect()
|
||||
};
|
||||
popup.cleanup = autoUpdate(referenceElement, popup.element, updatePosition);
|
||||
}
|
||||
|
||||
scrollHandler = updatePosition;
|
||||
view.dom.parentElement?.addEventListener('scroll', scrollHandler);
|
||||
}
|
||||
},
|
||||
|
||||
onUpdate(props: SuggestionProps) {
|
||||
component.updateProps(props);
|
||||
|
||||
if (popup.element && popup.isVisible && props.clientRect) {
|
||||
const rect = props.clientRect();
|
||||
if (rect) {
|
||||
const referenceElement = {
|
||||
getBoundingClientRect: () => rect
|
||||
};
|
||||
|
||||
computePosition(referenceElement, popup.element, {
|
||||
placement: 'bottom-start' as Placement,
|
||||
middleware: [
|
||||
offset({ mainAxis: 8, crossAxis: 16 }),
|
||||
flip({ fallbackPlacements: ['top-start', 'bottom-start'] })
|
||||
]
|
||||
}).then(({ x, y }) => {
|
||||
if (popup.element) {
|
||||
popup.element.style.left = `${x}px`;
|
||||
popup.element.style.top = `${y}px`;
|
||||
}
|
||||
});
|
||||
|
||||
props.editor.storage[extensionName].rect = rect;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
onKeyDown(props: SuggestionKeyDownProps) {
|
||||
if (props.event.key === 'Escape') {
|
||||
if (popup.element) {
|
||||
popup.element.style.visibility = 'hidden';
|
||||
popup.element.style.pointerEvents = 'none';
|
||||
popup.isVisible = false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!popup.isVisible && popup.element) {
|
||||
popup.element.style.visibility = 'visible';
|
||||
popup.element.style.pointerEvents = 'auto';
|
||||
popup.isVisible = true;
|
||||
}
|
||||
|
||||
if (props.event.key === 'Enter') return true;
|
||||
|
||||
// return component.ref?.onKeyDown(props);
|
||||
return false;
|
||||
},
|
||||
|
||||
onExit(props) {
|
||||
if (popup.element) {
|
||||
popup.element.style.visibility = 'hidden';
|
||||
popup.element.style.pointerEvents = 'none';
|
||||
popup.element.innerHTML = '';
|
||||
popup.isVisible = false;
|
||||
}
|
||||
|
||||
if (popup.cleanup) {
|
||||
popup.cleanup();
|
||||
popup.cleanup = null;
|
||||
}
|
||||
|
||||
if (scrollHandler) {
|
||||
const { view } = props.editor;
|
||||
view.dom.parentElement?.removeEventListener('scroll', scrollHandler);
|
||||
scrollHandler = null;
|
||||
}
|
||||
|
||||
component.destroy();
|
||||
}
|
||||
};
|
||||
}
|
||||
})
|
||||
];
|
||||
},
|
||||
|
||||
addStorage() {
|
||||
return {
|
||||
rect: {
|
||||
width: 0,
|
||||
height: 0,
|
||||
left: 0,
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 0
|
||||
}
|
||||
};
|
||||
}
|
||||
});
|
||||
4
src/lib/components/edra/extensions/table/index.ts
Normal file
4
src/lib/components/edra/extensions/table/index.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export { Table } from './table.js';
|
||||
export { TableCell } from './table-cell.js';
|
||||
export { TableRow } from './table-row.js';
|
||||
export { TableHeader } from './table-header.js';
|
||||
124
src/lib/components/edra/extensions/table/table-cell.ts
Normal file
124
src/lib/components/edra/extensions/table/table-cell.ts
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
import { mergeAttributes, Node } from '@tiptap/core';
|
||||
import { Plugin } from '@tiptap/pm/state';
|
||||
import { Decoration, DecorationSet } from '@tiptap/pm/view';
|
||||
|
||||
import { getCellsInColumn, isRowSelected, selectRow } from './utils.js';
|
||||
|
||||
export interface TableCellOptions {
|
||||
HTMLAttributes: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export const TableCell = Node.create<TableCellOptions>({
|
||||
name: 'tableCell',
|
||||
|
||||
content: 'block+',
|
||||
tableRole: 'cell',
|
||||
|
||||
isolating: true,
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
HTMLAttributes: {}
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [{ tag: 'td' }];
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return ['td', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0];
|
||||
},
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
colspan: {
|
||||
default: 1,
|
||||
parseHTML: (element) => {
|
||||
const colspan = element.getAttribute('colspan');
|
||||
const value = colspan ? parseInt(colspan, 10) : 1;
|
||||
|
||||
return value;
|
||||
}
|
||||
},
|
||||
rowspan: {
|
||||
default: 1,
|
||||
parseHTML: (element) => {
|
||||
const rowspan = element.getAttribute('rowspan');
|
||||
const value = rowspan ? parseInt(rowspan, 10) : 1;
|
||||
|
||||
return value;
|
||||
}
|
||||
},
|
||||
colwidth: {
|
||||
default: null,
|
||||
parseHTML: (element) => {
|
||||
const colwidth = element.getAttribute('colwidth');
|
||||
const value = colwidth ? [parseInt(colwidth, 10)] : null;
|
||||
|
||||
return value;
|
||||
}
|
||||
},
|
||||
style: {
|
||||
default: null
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
const { isEditable } = this.editor;
|
||||
|
||||
return [
|
||||
new Plugin({
|
||||
props: {
|
||||
decorations: (state) => {
|
||||
if (!isEditable) {
|
||||
return DecorationSet.empty;
|
||||
}
|
||||
|
||||
const { doc, selection } = state;
|
||||
const decorations: Decoration[] = [];
|
||||
const cells = getCellsInColumn(0)(selection);
|
||||
|
||||
if (cells) {
|
||||
cells.forEach(({ pos }: { pos: number }, index: number) => {
|
||||
decorations.push(
|
||||
Decoration.widget(pos + 1, () => {
|
||||
const rowSelected = isRowSelected(index)(selection);
|
||||
let className = 'grip-row';
|
||||
|
||||
if (rowSelected) {
|
||||
className += ' selected';
|
||||
}
|
||||
|
||||
if (index === 0) {
|
||||
className += ' first';
|
||||
}
|
||||
|
||||
if (index === cells.length - 1) {
|
||||
className += ' last';
|
||||
}
|
||||
|
||||
const grip = document.createElement('a');
|
||||
|
||||
grip.className = className;
|
||||
grip.addEventListener('mousedown', (event) => {
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
|
||||
this.editor.view.dispatch(selectRow(index)(this.editor.state.tr));
|
||||
});
|
||||
|
||||
return grip;
|
||||
})
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
return DecorationSet.create(doc, decorations);
|
||||
}
|
||||
}
|
||||
})
|
||||
];
|
||||
}
|
||||
});
|
||||
89
src/lib/components/edra/extensions/table/table-header.ts
Normal file
89
src/lib/components/edra/extensions/table/table-header.ts
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
import { TableHeader as TiptapTableHeader } from '@tiptap/extension-table';
|
||||
import { Plugin } from '@tiptap/pm/state';
|
||||
import { Decoration, DecorationSet } from '@tiptap/pm/view';
|
||||
|
||||
import { getCellsInRow, isColumnSelected, selectColumn } from './utils.js';
|
||||
|
||||
export const TableHeader = TiptapTableHeader.extend({
|
||||
addAttributes() {
|
||||
return {
|
||||
colspan: {
|
||||
default: 1
|
||||
},
|
||||
rowspan: {
|
||||
default: 1
|
||||
},
|
||||
colwidth: {
|
||||
default: null,
|
||||
parseHTML: (element) => {
|
||||
const colwidth = element.getAttribute('colwidth');
|
||||
const value = colwidth ? colwidth.split(',').map((item) => parseInt(item, 10)) : null;
|
||||
|
||||
return value;
|
||||
}
|
||||
},
|
||||
style: {
|
||||
default: null
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
const { isEditable } = this.editor;
|
||||
|
||||
return [
|
||||
new Plugin({
|
||||
props: {
|
||||
decorations: (state) => {
|
||||
if (!isEditable) {
|
||||
return DecorationSet.empty;
|
||||
}
|
||||
|
||||
const { doc, selection } = state;
|
||||
const decorations: Decoration[] = [];
|
||||
const cells = getCellsInRow(0)(selection);
|
||||
|
||||
if (cells) {
|
||||
cells.forEach(({ pos }: { pos: number }, index: number) => {
|
||||
decorations.push(
|
||||
Decoration.widget(pos + 1, () => {
|
||||
const colSelected = isColumnSelected(index)(selection);
|
||||
let className = 'grip-column';
|
||||
|
||||
if (colSelected) {
|
||||
className += ' selected';
|
||||
}
|
||||
|
||||
if (index === 0) {
|
||||
className += ' first';
|
||||
}
|
||||
|
||||
if (index === cells.length - 1) {
|
||||
className += ' last';
|
||||
}
|
||||
|
||||
const grip = document.createElement('a');
|
||||
|
||||
grip.className = className;
|
||||
grip.addEventListener('mousedown', (event) => {
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
|
||||
this.editor.view.dispatch(selectColumn(index)(this.editor.state.tr));
|
||||
});
|
||||
|
||||
return grip;
|
||||
})
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
return DecorationSet.create(doc, decorations);
|
||||
}
|
||||
}
|
||||
})
|
||||
];
|
||||
}
|
||||
});
|
||||
|
||||
export default TableHeader;
|
||||
8
src/lib/components/edra/extensions/table/table-row.ts
Normal file
8
src/lib/components/edra/extensions/table/table-row.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import { TableRow as TiptapTableRow } from '@tiptap/extension-table';
|
||||
|
||||
export const TableRow = TiptapTableRow.extend({
|
||||
allowGapCursor: false,
|
||||
content: '(tableCell | tableHeader)*'
|
||||
});
|
||||
|
||||
export default TableRow;
|
||||
9
src/lib/components/edra/extensions/table/table.ts
Normal file
9
src/lib/components/edra/extensions/table/table.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { Table as TiptapTable } from '@tiptap/extension-table';
|
||||
|
||||
export const Table = TiptapTable.configure({
|
||||
resizable: true,
|
||||
lastColumnResizable: true,
|
||||
allowTableNodeSelection: true
|
||||
});
|
||||
|
||||
export default Table;
|
||||
322
src/lib/components/edra/extensions/table/utils.ts
Normal file
322
src/lib/components/edra/extensions/table/utils.ts
Normal file
|
|
@ -0,0 +1,322 @@
|
|||
import { Editor, findParentNode } from '@tiptap/core';
|
||||
import { EditorState, Selection, Transaction } from '@tiptap/pm/state';
|
||||
import { CellSelection, type Rect, TableMap } from '@tiptap/pm/tables';
|
||||
import { Node, ResolvedPos } from '@tiptap/pm/model';
|
||||
import type { EditorView } from '@tiptap/pm/view';
|
||||
import Table from './table.js';
|
||||
|
||||
export const isRectSelected = (rect: Rect) => (selection: CellSelection) => {
|
||||
const map = TableMap.get(selection.$anchorCell.node(-1));
|
||||
const start = selection.$anchorCell.start(-1);
|
||||
const cells = map.cellsInRect(rect);
|
||||
const selectedCells = map.cellsInRect(
|
||||
map.rectBetween(selection.$anchorCell.pos - start, selection.$headCell.pos - start)
|
||||
);
|
||||
|
||||
for (let i = 0, count = cells.length; i < count; i += 1) {
|
||||
if (selectedCells.indexOf(cells[i]) === -1) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
export const findTable = (selection: Selection) =>
|
||||
findParentNode((node) => node.type.spec.tableRole && node.type.spec.tableRole === 'table')(
|
||||
selection
|
||||
);
|
||||
|
||||
export const isCellSelection = (selection: Selection): selection is CellSelection =>
|
||||
selection instanceof CellSelection;
|
||||
|
||||
export const isColumnSelected = (columnIndex: number) => (selection: Selection) => {
|
||||
if (isCellSelection(selection)) {
|
||||
const map = TableMap.get(selection.$anchorCell.node(-1));
|
||||
|
||||
return isRectSelected({
|
||||
left: columnIndex,
|
||||
right: columnIndex + 1,
|
||||
top: 0,
|
||||
bottom: map.height
|
||||
})(selection);
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
export const isRowSelected = (rowIndex: number) => (selection: Selection) => {
|
||||
if (isCellSelection(selection)) {
|
||||
const map = TableMap.get(selection.$anchorCell.node(-1));
|
||||
|
||||
return isRectSelected({
|
||||
left: 0,
|
||||
right: map.width,
|
||||
top: rowIndex,
|
||||
bottom: rowIndex + 1
|
||||
})(selection);
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
export const isTableSelected = (selection: Selection) => {
|
||||
if (isCellSelection(selection)) {
|
||||
const map = TableMap.get(selection.$anchorCell.node(-1));
|
||||
|
||||
return isRectSelected({
|
||||
left: 0,
|
||||
right: map.width,
|
||||
top: 0,
|
||||
bottom: map.height
|
||||
})(selection);
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
export const getCellsInColumn = (columnIndex: number | number[]) => (selection: Selection) => {
|
||||
const table = findTable(selection);
|
||||
if (table) {
|
||||
const map = TableMap.get(table.node);
|
||||
const indexes = Array.isArray(columnIndex) ? columnIndex : Array.from([columnIndex]);
|
||||
|
||||
return indexes.reduce(
|
||||
(acc, index) => {
|
||||
if (index >= 0 && index <= map.width - 1) {
|
||||
const cells = map.cellsInRect({
|
||||
left: index,
|
||||
right: index + 1,
|
||||
top: 0,
|
||||
bottom: map.height
|
||||
});
|
||||
|
||||
return acc.concat(
|
||||
cells.map((nodePos) => {
|
||||
const node = table.node.nodeAt(nodePos);
|
||||
const pos = nodePos + table.start;
|
||||
|
||||
return { pos, start: pos + 1, node };
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return acc;
|
||||
},
|
||||
[] as { pos: number; start: number; node: Node | null | undefined }[]
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export const getCellsInRow = (rowIndex: number | number[]) => (selection: Selection) => {
|
||||
const table = findTable(selection);
|
||||
|
||||
if (table) {
|
||||
const map = TableMap.get(table.node);
|
||||
const indexes = Array.isArray(rowIndex) ? rowIndex : Array.from([rowIndex]);
|
||||
|
||||
return indexes.reduce(
|
||||
(acc, index) => {
|
||||
if (index >= 0 && index <= map.height - 1) {
|
||||
const cells = map.cellsInRect({
|
||||
left: 0,
|
||||
right: map.width,
|
||||
top: index,
|
||||
bottom: index + 1
|
||||
});
|
||||
|
||||
return acc.concat(
|
||||
cells.map((nodePos) => {
|
||||
const node = table.node.nodeAt(nodePos);
|
||||
const pos = nodePos + table.start;
|
||||
return { pos, start: pos + 1, node };
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return acc;
|
||||
},
|
||||
[] as { pos: number; start: number; node: Node | null | undefined }[]
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const getCellsInTable = (selection: Selection) => {
|
||||
const table = findTable(selection);
|
||||
|
||||
if (table) {
|
||||
const map = TableMap.get(table.node);
|
||||
const cells = map.cellsInRect({
|
||||
left: 0,
|
||||
right: map.width,
|
||||
top: 0,
|
||||
bottom: map.height
|
||||
});
|
||||
|
||||
return cells.map((nodePos) => {
|
||||
const node = table.node.nodeAt(nodePos);
|
||||
const pos = nodePos + table.start;
|
||||
|
||||
return { pos, start: pos + 1, node };
|
||||
});
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const findParentNodeClosestToPos = (
|
||||
$pos: ResolvedPos,
|
||||
predicate: (node: Node) => boolean
|
||||
) => {
|
||||
for (let i = $pos.depth; i > 0; i -= 1) {
|
||||
const node = $pos.node(i);
|
||||
|
||||
if (predicate(node)) {
|
||||
return {
|
||||
pos: i > 0 ? $pos.before(i) : 0,
|
||||
start: $pos.start(i),
|
||||
depth: i,
|
||||
node
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const findCellClosestToPos = ($pos: ResolvedPos) => {
|
||||
const predicate = (node: Node) =>
|
||||
node.type.spec.tableRole && /cell/i.test(node.type.spec.tableRole);
|
||||
|
||||
return findParentNodeClosestToPos($pos, predicate);
|
||||
};
|
||||
|
||||
const select = (type: 'row' | 'column') => (index: number) => (tr: Transaction) => {
|
||||
const table = findTable(tr.selection);
|
||||
const isRowSelection = type === 'row';
|
||||
|
||||
if (table) {
|
||||
const map = TableMap.get(table.node);
|
||||
|
||||
// Check if the index is valid
|
||||
if (index >= 0 && index < (isRowSelection ? map.height : map.width)) {
|
||||
const left = isRowSelection ? 0 : index;
|
||||
const top = isRowSelection ? index : 0;
|
||||
const right = isRowSelection ? map.width : index + 1;
|
||||
const bottom = isRowSelection ? index + 1 : map.height;
|
||||
|
||||
const cellsInFirstRow = map.cellsInRect({
|
||||
left,
|
||||
top,
|
||||
right: isRowSelection ? right : left + 1,
|
||||
bottom: isRowSelection ? top + 1 : bottom
|
||||
});
|
||||
|
||||
const cellsInLastRow =
|
||||
bottom - top === 1
|
||||
? cellsInFirstRow
|
||||
: map.cellsInRect({
|
||||
left: isRowSelection ? left : right - 1,
|
||||
top: isRowSelection ? bottom - 1 : top,
|
||||
right,
|
||||
bottom
|
||||
});
|
||||
|
||||
const head = table.start + cellsInFirstRow[0];
|
||||
const anchor = table.start + cellsInLastRow[cellsInLastRow.length - 1];
|
||||
const $head = tr.doc.resolve(head);
|
||||
const $anchor = tr.doc.resolve(anchor);
|
||||
|
||||
return tr.setSelection(new CellSelection($anchor, $head));
|
||||
}
|
||||
}
|
||||
return tr;
|
||||
};
|
||||
|
||||
export const selectColumn = select('column');
|
||||
|
||||
export const selectRow = select('row');
|
||||
|
||||
export const selectTable = (tr: Transaction) => {
|
||||
const table = findTable(tr.selection);
|
||||
|
||||
if (table) {
|
||||
const { map } = TableMap.get(table.node);
|
||||
|
||||
if (map && map.length) {
|
||||
const head = table.start + map[0];
|
||||
const anchor = table.start + map[map.length - 1];
|
||||
const $head = tr.doc.resolve(head);
|
||||
const $anchor = tr.doc.resolve(anchor);
|
||||
|
||||
return tr.setSelection(new CellSelection($anchor, $head));
|
||||
}
|
||||
}
|
||||
|
||||
return tr;
|
||||
};
|
||||
|
||||
export const isColumnGripSelected = ({
|
||||
editor,
|
||||
view,
|
||||
state,
|
||||
from
|
||||
}: {
|
||||
editor: Editor;
|
||||
view: EditorView;
|
||||
state: EditorState;
|
||||
from: number;
|
||||
}) => {
|
||||
const domAtPos = view.domAtPos(from).node as HTMLElement;
|
||||
const nodeDOM = view.nodeDOM(from) as HTMLElement;
|
||||
const node = nodeDOM || domAtPos;
|
||||
|
||||
if (!editor.isActive(Table.name) || !node || isTableSelected(state.selection)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let container = node;
|
||||
|
||||
while (container && !['TD', 'TH'].includes(container.tagName)) {
|
||||
container = container.parentElement!;
|
||||
}
|
||||
|
||||
const gripColumn =
|
||||
container && container.querySelector && container.querySelector('a.grip-column.selected');
|
||||
|
||||
return !!gripColumn;
|
||||
};
|
||||
|
||||
export const isRowGripSelected = ({
|
||||
editor,
|
||||
view,
|
||||
state,
|
||||
from
|
||||
}: {
|
||||
editor: Editor;
|
||||
view: EditorView;
|
||||
state: EditorState;
|
||||
from: number;
|
||||
}) => {
|
||||
const domAtPos = view.domAtPos(from).node as HTMLElement;
|
||||
const nodeDOM = view.nodeDOM(from) as HTMLElement;
|
||||
const node = nodeDOM || domAtPos;
|
||||
|
||||
if (!editor.isActive(Table.name) || !node || isTableSelected(state.selection)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let container = node;
|
||||
|
||||
while (container && !['TD', 'TH'].includes(container.tagName)) {
|
||||
container = container.parentElement!;
|
||||
}
|
||||
|
||||
const gripRow =
|
||||
container && container.querySelector && container.querySelector('a.grip-row.selected');
|
||||
|
||||
return !!gripRow;
|
||||
};
|
||||
34
src/lib/components/edra/extensions/video/VideoExtended.ts
Normal file
34
src/lib/components/edra/extensions/video/VideoExtended.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import { SvelteNodeViewRenderer } from 'svelte-tiptap';
|
||||
import { Video } from './VideoExtension.js';
|
||||
import type { NodeViewProps } from '@tiptap/core';
|
||||
import type { Component } from 'svelte';
|
||||
|
||||
export const VideoExtended = (content: Component<NodeViewProps>) =>
|
||||
Video.extend({
|
||||
addAttributes() {
|
||||
return {
|
||||
src: {
|
||||
default: null
|
||||
},
|
||||
alt: {
|
||||
default: null
|
||||
},
|
||||
title: {
|
||||
default: null
|
||||
},
|
||||
width: {
|
||||
default: '100%'
|
||||
},
|
||||
height: {
|
||||
default: null
|
||||
},
|
||||
align: {
|
||||
default: 'left'
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
addNodeView: () => {
|
||||
return SvelteNodeViewRenderer(content);
|
||||
}
|
||||
});
|
||||
147
src/lib/components/edra/extensions/video/VideoExtension.ts
Normal file
147
src/lib/components/edra/extensions/video/VideoExtension.ts
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
import { Node, nodeInputRule } from '@tiptap/core';
|
||||
import { Plugin, PluginKey } from '@tiptap/pm/state';
|
||||
|
||||
export interface VideoOptions {
|
||||
HTMLAttributes: Record<string, unknown>;
|
||||
}
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
interface Commands<ReturnType> {
|
||||
video: {
|
||||
/**
|
||||
* Set a video node
|
||||
*/
|
||||
setVideo: (src: string) => ReturnType;
|
||||
/**
|
||||
* Toggle a video
|
||||
*/
|
||||
toggleVideo: (src: string) => ReturnType;
|
||||
/**
|
||||
* Remove a video
|
||||
*/
|
||||
removeVideo: () => ReturnType;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const VIDEO_INPUT_REGEX = /!\[(.+|:?)]\((\S+)(?:(?:\s+)["'](\S+)["'])?\)/;
|
||||
|
||||
export const Video = Node.create<VideoOptions>({
|
||||
name: 'video',
|
||||
group: 'block',
|
||||
content: 'inline*',
|
||||
draggable: true,
|
||||
isolating: true,
|
||||
addOptions() {
|
||||
return {
|
||||
HTMLAttributes: {}
|
||||
};
|
||||
},
|
||||
addAttributes() {
|
||||
return {
|
||||
src: {
|
||||
default: null,
|
||||
parseHTML: (el) => (el as HTMLSpanElement).getAttribute('src'),
|
||||
renderHTML: (attrs) => ({ src: attrs.src })
|
||||
}
|
||||
};
|
||||
},
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: 'video',
|
||||
getAttrs: (el) => ({ src: (el as HTMLVideoElement).getAttribute('src') })
|
||||
}
|
||||
];
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return [
|
||||
'video',
|
||||
{ controls: 'true', style: 'width: fit-content;', ...HTMLAttributes },
|
||||
['source', HTMLAttributes]
|
||||
];
|
||||
},
|
||||
addCommands() {
|
||||
return {
|
||||
setVideo:
|
||||
(src: string) =>
|
||||
({ commands }) =>
|
||||
commands.insertContent(
|
||||
`<video controls="true" autoplay="false" style="width: fit-content" src="${src}" />`
|
||||
),
|
||||
|
||||
toggleVideo:
|
||||
() =>
|
||||
({ commands }) =>
|
||||
commands.toggleNode(this.name, 'paragraph'),
|
||||
removeVideo:
|
||||
() =>
|
||||
({ commands }) =>
|
||||
commands.deleteNode(this.name)
|
||||
};
|
||||
},
|
||||
addInputRules() {
|
||||
return [
|
||||
nodeInputRule({
|
||||
find: VIDEO_INPUT_REGEX,
|
||||
type: this.type,
|
||||
getAttributes: (match) => {
|
||||
const [, , src] = match;
|
||||
|
||||
return { src };
|
||||
}
|
||||
})
|
||||
];
|
||||
},
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
new Plugin({
|
||||
key: new PluginKey('videoDropPlugin'),
|
||||
|
||||
props: {
|
||||
handleDOMEvents: {
|
||||
drop(view, event) {
|
||||
const {
|
||||
state: { schema, tr },
|
||||
dispatch
|
||||
} = view;
|
||||
const hasFiles =
|
||||
event.dataTransfer && event.dataTransfer.files && event.dataTransfer.files.length;
|
||||
|
||||
if (!hasFiles) return false;
|
||||
|
||||
const videos = Array.from(event.dataTransfer.files).filter((file) =>
|
||||
/video/i.test(file.type)
|
||||
);
|
||||
|
||||
if (videos.length === 0) return false;
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
const coordinates = view.posAtCoords({ left: event.clientX, top: event.clientY });
|
||||
|
||||
videos.forEach((video) => {
|
||||
const reader = new FileReader();
|
||||
|
||||
reader.onload = (readerEvent) => {
|
||||
const node = schema.nodes.video.create({ src: readerEvent.target?.result });
|
||||
|
||||
if (coordinates && typeof coordinates.pos === 'number') {
|
||||
const transaction = tr.insert(coordinates?.pos, node);
|
||||
|
||||
dispatch(transaction);
|
||||
}
|
||||
};
|
||||
|
||||
reader.readAsDataURL(video);
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
];
|
||||
}
|
||||
});
|
||||
62
src/lib/components/edra/extensions/video/VideoPlaceholder.ts
Normal file
62
src/lib/components/edra/extensions/video/VideoPlaceholder.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import { Editor, Node, mergeAttributes, type CommandProps, type NodeViewProps } from '@tiptap/core';
|
||||
import type { Component } from 'svelte';
|
||||
import { SvelteNodeViewRenderer } from 'svelte-tiptap';
|
||||
|
||||
export interface VideoPlaceholderOptions {
|
||||
HTMLAttributes: Record<string, object>;
|
||||
onDrop: (files: File[], editor: Editor) => void;
|
||||
onDropRejected?: (files: File[], editor: Editor) => void;
|
||||
onEmbed: (url: string, editor: Editor) => void;
|
||||
allowedMimeTypes?: Record<string, string[]>;
|
||||
maxFiles?: number;
|
||||
maxSize?: number;
|
||||
}
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
interface Commands<ReturnType> {
|
||||
videoPlaceholder: {
|
||||
/**
|
||||
* Inserts a video placeholder
|
||||
*/
|
||||
insertVideoPlaceholder: () => ReturnType;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const VideoPlaceholder = (content: Component<NodeViewProps>) =>
|
||||
Node.create<VideoPlaceholderOptions>({
|
||||
name: 'video-placeholder',
|
||||
addOptions() {
|
||||
return {
|
||||
HTMLAttributes: {},
|
||||
onDrop: () => {},
|
||||
onDropRejected: () => {},
|
||||
onEmbed: () => {}
|
||||
};
|
||||
},
|
||||
parseHTML() {
|
||||
return [{ tag: `div[data-type="${this.name}"]` }];
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return ['div', mergeAttributes(HTMLAttributes)];
|
||||
},
|
||||
group: 'block',
|
||||
draggable: true,
|
||||
atom: true,
|
||||
content: 'inline*',
|
||||
isolating: true,
|
||||
|
||||
addNodeView() {
|
||||
return SvelteNodeViewRenderer(content);
|
||||
},
|
||||
addCommands() {
|
||||
return {
|
||||
insertVideoPlaceholder: () => (props: CommandProps) => {
|
||||
return props.commands.insertContent({
|
||||
type: 'video-placeholder'
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
});
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
<script lang="ts">
|
||||
import type { NodeViewProps } from '@tiptap/core';
|
||||
import MediaExtended from './MediaExtended.svelte';
|
||||
|
||||
const { ...rest }: NodeViewProps = $props();
|
||||
|
||||
let mediaRef = $state<HTMLElement>();
|
||||
</script>
|
||||
|
||||
<MediaExtended bind:mediaRef {...rest}>
|
||||
{@const node = rest.node}
|
||||
<audio bind:this={mediaRef} src={node.attrs.src} controls title={node.attrs.title}> </audio>
|
||||
</MediaExtended>
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
<script lang="ts">
|
||||
import MediaPlaceHolder from '../../components/MediaPlaceHolder.svelte';
|
||||
import type { NodeViewProps } from '@tiptap/core';
|
||||
|
||||
const { editor }: NodeViewProps = $props();
|
||||
import Audio from '@lucide/svelte/icons/audio-lines';
|
||||
|
||||
function handleClick() {
|
||||
const audioUrl = prompt('Please enter the audio URL');
|
||||
if (audioUrl) {
|
||||
editor.chain().focus().setAudio(audioUrl).run();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<MediaPlaceHolder
|
||||
class="edra-media-placeholder-wrapper"
|
||||
icon={Audio}
|
||||
title="Insert an audio"
|
||||
onClick={handleClick}
|
||||
/>
|
||||
47
src/lib/components/edra/headless/components/CodeBlock.svelte
Normal file
47
src/lib/components/edra/headless/components/CodeBlock.svelte
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
<script lang="ts">
|
||||
import { NodeViewWrapper, NodeViewContent } from 'svelte-tiptap';
|
||||
import type { NodeViewProps } from '@tiptap/core';
|
||||
const { node, updateAttributes, extension }: NodeViewProps = $props();
|
||||
|
||||
let preRef = $state<HTMLPreElement>();
|
||||
|
||||
let isCopying = $state(false);
|
||||
|
||||
const languages: string[] = extension.options.lowlight.listLanguages().sort();
|
||||
|
||||
let defaultLanguage = $state(node.attrs.language);
|
||||
|
||||
$effect(() => {
|
||||
updateAttributes({ language: defaultLanguage });
|
||||
});
|
||||
|
||||
function copyCode() {
|
||||
if (isCopying) return;
|
||||
if (!preRef) return;
|
||||
isCopying = true;
|
||||
navigator.clipboard.writeText(preRef.innerText);
|
||||
setTimeout(() => {
|
||||
isCopying = false;
|
||||
}, 1000);
|
||||
}
|
||||
</script>
|
||||
|
||||
<NodeViewWrapper class="code-wrapper">
|
||||
<div class="code-wrapper-tile" contenteditable="false">
|
||||
<select bind:value={defaultLanguage} class="code-wrapper-select browser-default">
|
||||
{#each languages as language (language)}
|
||||
<option value={language}>{language}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<button class="code-wrapper-copy" onclick={copyCode}>
|
||||
{#if isCopying}
|
||||
<span class="code-wrapper-copy-text copied">Copied!</span>
|
||||
{:else}
|
||||
<span class="code-wrapper-copy-text">Copy</span>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
<pre bind:this={preRef} spellcheck="false">
|
||||
<NodeViewContent as="code" class={`language-${defaultLanguage}`} {...node.attrs} />
|
||||
</pre>
|
||||
</NodeViewWrapper>
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
<script lang="ts">
|
||||
import type { NodeViewProps } from '@tiptap/core';
|
||||
import MediaExtended from './MediaExtended.svelte';
|
||||
|
||||
const { ...rest }: NodeViewProps = $props();
|
||||
|
||||
let mediaRef = $state<HTMLElement>();
|
||||
</script>
|
||||
|
||||
<MediaExtended bind:mediaRef {...rest}>
|
||||
{@const node = rest.node}
|
||||
<iframe bind:this={mediaRef} {...node.attrs}> </iframe>
|
||||
</MediaExtended>
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
<script lang="ts">
|
||||
import MediaPlaceHolder from '../../components/MediaPlaceHolder.svelte';
|
||||
import type { NodeViewProps } from '@tiptap/core';
|
||||
|
||||
const { editor }: NodeViewProps = $props();
|
||||
import Audio from '@lucide/svelte/icons/code-xml';
|
||||
|
||||
function handleClick() {
|
||||
const iframUrl = prompt('Please enter the IFrame URL');
|
||||
if (iframUrl) {
|
||||
editor.chain().focus().setIframe({ src: iframUrl }).run();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<MediaPlaceHolder
|
||||
class="edra-media-placeholder-wrapper"
|
||||
icon={Audio}
|
||||
title="Insert an iframe"
|
||||
onClick={handleClick}
|
||||
/>
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
<script lang="ts">
|
||||
import type { NodeViewProps } from '@tiptap/core';
|
||||
import MediaExtended from './MediaExtended.svelte';
|
||||
|
||||
const { ...rest }: NodeViewProps = $props();
|
||||
|
||||
let mediaRef = $state<HTMLElement>();
|
||||
</script>
|
||||
|
||||
<MediaExtended bind:mediaRef {...rest}>
|
||||
{@const node = rest.node}
|
||||
<img bind:this={mediaRef} src={node.attrs.src} alt={node.attrs.alt} title={node.attrs.title} />
|
||||
</MediaExtended>
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
<script lang="ts">
|
||||
import MediaPlaceHolder from '../../components/MediaPlaceHolder.svelte';
|
||||
import type { NodeViewProps } from '@tiptap/core';
|
||||
|
||||
const { editor }: NodeViewProps = $props();
|
||||
import Image from '@lucide/svelte/icons/image';
|
||||
|
||||
function handleClick() {
|
||||
const imageUrl = prompt('Please enter the image URL');
|
||||
if (imageUrl) {
|
||||
editor.chain().focus().setImage({ src: imageUrl }).run();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<MediaPlaceHolder
|
||||
class="edra-media-placeholder-wrapper"
|
||||
icon={Image}
|
||||
title="Insert an image"
|
||||
onClick={handleClick}
|
||||
/>
|
||||
243
src/lib/components/edra/headless/components/MediaExtended.svelte
Normal file
243
src/lib/components/edra/headless/components/MediaExtended.svelte
Normal file
|
|
@ -0,0 +1,243 @@
|
|||
<script lang="ts">
|
||||
import { onDestroy, onMount, type Snippet } from 'svelte';
|
||||
import { NodeViewWrapper } from 'svelte-tiptap';
|
||||
import type { NodeViewProps } from '@tiptap/core';
|
||||
|
||||
import AlignCenter from '@lucide/svelte/icons/align-center';
|
||||
import AlignLeft from '@lucide/svelte/icons/align-left';
|
||||
import AlignRight from '@lucide/svelte/icons/align-right';
|
||||
import CopyIcon from '@lucide/svelte/icons/copy';
|
||||
import Fullscreen from '@lucide/svelte/icons/fullscreen';
|
||||
import Trash from '@lucide/svelte/icons/trash';
|
||||
import Captions from '@lucide/svelte/icons/captions';
|
||||
|
||||
import { duplicateContent } from '../../utils.js';
|
||||
|
||||
interface MediaExtendedProps extends NodeViewProps {
|
||||
children: Snippet<[]>;
|
||||
mediaRef?: HTMLElement;
|
||||
}
|
||||
|
||||
const {
|
||||
node,
|
||||
editor,
|
||||
selected,
|
||||
deleteNode,
|
||||
updateAttributes,
|
||||
children,
|
||||
mediaRef = $bindable()
|
||||
}: MediaExtendedProps = $props();
|
||||
|
||||
const minWidthPercent = 15;
|
||||
const maxWidthPercent = 100;
|
||||
|
||||
let nodeRef = $state<HTMLElement>();
|
||||
|
||||
let resizing = $state(false);
|
||||
let resizingInitialWidthPercent = $state(0);
|
||||
let resizingInitialMouseX = $state(0);
|
||||
let resizingPosition = $state<'left' | 'right'>('left');
|
||||
|
||||
let caption: string | null = $state(node.attrs.title);
|
||||
$effect(() => {
|
||||
if (caption?.trim() === '') caption = null;
|
||||
updateAttributes({ title: caption });
|
||||
});
|
||||
|
||||
function handleResizingPosition(e: MouseEvent, position: 'left' | 'right') {
|
||||
startResize(e);
|
||||
resizingPosition = position;
|
||||
}
|
||||
|
||||
function startResize(e: MouseEvent) {
|
||||
e.preventDefault();
|
||||
resizing = true;
|
||||
resizingInitialMouseX = e.clientX;
|
||||
if (mediaRef && nodeRef?.parentElement) {
|
||||
const currentWidth = mediaRef.offsetWidth;
|
||||
const parentWidth = nodeRef.parentElement.offsetWidth;
|
||||
resizingInitialWidthPercent = (currentWidth / parentWidth) * 100;
|
||||
}
|
||||
}
|
||||
|
||||
function resize(e: MouseEvent) {
|
||||
if (!resizing || !nodeRef?.parentElement) return;
|
||||
let dx = e.clientX - resizingInitialMouseX;
|
||||
if (resizingPosition === 'left') {
|
||||
dx = resizingInitialMouseX - e.clientX;
|
||||
}
|
||||
const parentWidth = nodeRef.parentElement.offsetWidth;
|
||||
const deltaPercent = (dx / parentWidth) * 100;
|
||||
const newWidthPercent = Math.max(
|
||||
Math.min(resizingInitialWidthPercent + deltaPercent, maxWidthPercent),
|
||||
minWidthPercent
|
||||
);
|
||||
updateAttributes({ width: `${newWidthPercent}%` });
|
||||
}
|
||||
|
||||
function endResize() {
|
||||
resizing = false;
|
||||
resizingInitialMouseX = 0;
|
||||
resizingInitialWidthPercent = 0;
|
||||
}
|
||||
|
||||
function handleTouchStart(e: TouchEvent, position: 'left' | 'right') {
|
||||
e.preventDefault();
|
||||
resizing = true;
|
||||
resizingPosition = position;
|
||||
resizingInitialMouseX = e.touches[0].clientX;
|
||||
if (mediaRef && nodeRef?.parentElement) {
|
||||
const currentWidth = mediaRef.offsetWidth;
|
||||
const parentWidth = nodeRef.parentElement.offsetWidth;
|
||||
resizingInitialWidthPercent = (currentWidth / parentWidth) * 100;
|
||||
}
|
||||
}
|
||||
|
||||
function handleTouchMove(e: TouchEvent) {
|
||||
if (!resizing || !nodeRef?.parentElement) return;
|
||||
let dx = e.touches[0].clientX - resizingInitialMouseX;
|
||||
if (resizingPosition === 'left') {
|
||||
dx = resizingInitialMouseX - e.touches[0].clientX;
|
||||
}
|
||||
const parentWidth = nodeRef.parentElement.offsetWidth;
|
||||
const deltaPercent = (dx / parentWidth) * 100;
|
||||
const newWidthPercent = Math.max(
|
||||
Math.min(resizingInitialWidthPercent + deltaPercent, maxWidthPercent),
|
||||
minWidthPercent
|
||||
);
|
||||
updateAttributes({ width: `${newWidthPercent}%` });
|
||||
}
|
||||
|
||||
function handleTouchEnd() {
|
||||
resizing = false;
|
||||
resizingInitialMouseX = 0;
|
||||
resizingInitialWidthPercent = 0;
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
// Attach id to nodeRef
|
||||
nodeRef = document.getElementById('resizable-container-media') as HTMLDivElement;
|
||||
|
||||
// Mouse events
|
||||
window.addEventListener('mousemove', resize);
|
||||
window.addEventListener('mouseup', endResize);
|
||||
// Touch events
|
||||
window.addEventListener('touchmove', handleTouchMove);
|
||||
window.addEventListener('touchend', handleTouchEnd);
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
window.removeEventListener('mousemove', resize);
|
||||
window.removeEventListener('mouseup', endResize);
|
||||
window.removeEventListener('touchmove', handleTouchMove);
|
||||
window.removeEventListener('touchend', handleTouchEnd);
|
||||
});
|
||||
</script>
|
||||
|
||||
<NodeViewWrapper
|
||||
id="resizable-container-media"
|
||||
style={`width: ${node.attrs.width}`}
|
||||
class={`edra-media-container ${selected ? 'selected' : ''} align-${node.attrs.align}`}
|
||||
>
|
||||
<div class={`edra-media-group ${resizing ? 'resizing' : ''}`}>
|
||||
{@render children()}
|
||||
|
||||
{#if caption !== null}
|
||||
<input bind:value={caption} type="text" class="edra-media-caption" />
|
||||
{/if}
|
||||
|
||||
{#if editor?.isEditable}
|
||||
<div
|
||||
role="button"
|
||||
tabindex="0"
|
||||
aria-label="Resize left"
|
||||
class="edra-media-resize-handle edra-media-resize-handle-left"
|
||||
onmousedown={(event: MouseEvent) => {
|
||||
handleResizingPosition(event, 'left');
|
||||
}}
|
||||
ontouchstart={(event: TouchEvent) => {
|
||||
handleTouchStart(event, 'left');
|
||||
}}
|
||||
>
|
||||
<div class="edra-media-resize-indicator"></div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
role="button"
|
||||
tabindex="0"
|
||||
aria-label="Resize right"
|
||||
class="edra-media-resize-handle edra-media-resize-handle-right"
|
||||
onmousedown={(event: MouseEvent) => {
|
||||
handleResizingPosition(event, 'right');
|
||||
}}
|
||||
ontouchstart={(event: TouchEvent) => {
|
||||
handleTouchStart(event, 'right');
|
||||
}}
|
||||
>
|
||||
<div class="edra-media-resize-indicator"></div>
|
||||
</div>
|
||||
|
||||
<div class="edra-media-toolbar edra-media-toolbar-audio">
|
||||
<button
|
||||
class={`edra-toolbar-button ${node.attrs.align === 'left' ? 'active' : ''}`}
|
||||
onclick={() => updateAttributes({ align: 'left' })}
|
||||
title="Align Left"
|
||||
>
|
||||
<AlignLeft />
|
||||
</button>
|
||||
<button
|
||||
class={`edra-toolbar-button ${node.attrs.align === 'center' ? 'active' : ''}`}
|
||||
onclick={() => updateAttributes({ align: 'center' })}
|
||||
title="Align Center"
|
||||
>
|
||||
<AlignCenter />
|
||||
</button>
|
||||
<button
|
||||
class={`edra-toolbar-button ${node.attrs.align === 'right' ? 'active' : ''}`}
|
||||
onclick={() => updateAttributes({ align: 'right' })}
|
||||
title="Align Right"
|
||||
>
|
||||
<AlignRight />
|
||||
</button>
|
||||
<button
|
||||
class="edra-toolbar-button"
|
||||
onclick={() => {
|
||||
if (caption === null || caption.trim() === '') caption = 'Audio Caption';
|
||||
}}
|
||||
title="Caption"
|
||||
>
|
||||
<Captions />
|
||||
</button>
|
||||
<button
|
||||
class="edra-toolbar-button"
|
||||
onclick={() => {
|
||||
duplicateContent(editor, node);
|
||||
}}
|
||||
title="Duplicate"
|
||||
>
|
||||
<CopyIcon />
|
||||
</button>
|
||||
<button
|
||||
class="edra-toolbar-button"
|
||||
onclick={() => {
|
||||
updateAttributes({
|
||||
width: 'fit-content'
|
||||
});
|
||||
}}
|
||||
title="Full Screen"
|
||||
>
|
||||
<Fullscreen />
|
||||
</button>
|
||||
<button
|
||||
class="edra-toolbar-button edra-destructive"
|
||||
onclick={() => {
|
||||
deleteNode();
|
||||
}}
|
||||
title="Delete"
|
||||
>
|
||||
<Trash />
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</NodeViewWrapper>
|
||||
|
|
@ -0,0 +1,114 @@
|
|||
<script lang="ts">
|
||||
interface Props {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
props: Record<string, any>;
|
||||
}
|
||||
|
||||
const { props }: Props = $props();
|
||||
|
||||
let scrollContainer = $state<HTMLElement | null>(null);
|
||||
|
||||
let selectedGroupIndex = $state<number>(0);
|
||||
let selectedCommandIndex = $state<number>(0);
|
||||
|
||||
const items = $derived.by(() => props.items);
|
||||
|
||||
$effect(() => {
|
||||
if (items) {
|
||||
selectedGroupIndex = 0;
|
||||
selectedCommandIndex = 0;
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const activeItem = document.getElementById(`${selectedGroupIndex}-${selectedCommandIndex}`);
|
||||
if (activeItem !== null && scrollContainer !== null) {
|
||||
const offsetTop = activeItem.offsetTop;
|
||||
const offsetHeight = activeItem.offsetHeight;
|
||||
scrollContainer.scrollTop = offsetTop - offsetHeight;
|
||||
}
|
||||
});
|
||||
|
||||
const selectItem = (groupIndex: number, commandIndex: number) => {
|
||||
const command = props.items[groupIndex].commands[commandIndex];
|
||||
props.command(command);
|
||||
};
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === 'ArrowDown' || ((e.ctrlKey || e.metaKey) && e.key === 'j') || e.key === 'Tab') {
|
||||
e.preventDefault();
|
||||
if (!props.items.length) {
|
||||
return false;
|
||||
}
|
||||
const commands = props.items[selectedGroupIndex].commands;
|
||||
let newCommandIndex = selectedCommandIndex + 1;
|
||||
let newGroupIndex = selectedGroupIndex;
|
||||
if (commands.length - 1 < newCommandIndex) {
|
||||
newCommandIndex = 0;
|
||||
newGroupIndex = selectedGroupIndex + 1;
|
||||
}
|
||||
|
||||
if (props.items.length - 1 < newGroupIndex) {
|
||||
newGroupIndex = 0;
|
||||
}
|
||||
selectedCommandIndex = newCommandIndex;
|
||||
selectedGroupIndex = newGroupIndex;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (e.key === 'ArrowUp' || ((e.ctrlKey || e.metaKey) && e.key === 'k')) {
|
||||
e.preventDefault();
|
||||
if (!props.items.length) {
|
||||
return false;
|
||||
}
|
||||
let newCommandIndex = selectedCommandIndex - 1;
|
||||
let newGroupIndex = selectedGroupIndex;
|
||||
if (newCommandIndex < 0) {
|
||||
newGroupIndex = selectedGroupIndex - 1;
|
||||
newCommandIndex = props.items[newGroupIndex]?.commands.length - 1 || 0;
|
||||
}
|
||||
if (newGroupIndex < 0) {
|
||||
newGroupIndex = props.items.length - 1;
|
||||
newCommandIndex = props.items[newGroupIndex].commands.length - 1;
|
||||
}
|
||||
selectedCommandIndex = newCommandIndex;
|
||||
selectedGroupIndex = newGroupIndex;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (!props.items.length || selectedGroupIndex === -1 || selectedCommandIndex === -1) {
|
||||
return false;
|
||||
}
|
||||
selectItem(selectedGroupIndex, selectedCommandIndex);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleKeyDown} />
|
||||
|
||||
{#if items.length}
|
||||
<div bind:this={scrollContainer} class="edra-slash-command-list">
|
||||
{#each items as grp, groupIndex (groupIndex)}
|
||||
<span class="edra-slash-command-list-title">{grp.title}</span>
|
||||
|
||||
{#each grp.commands as command, commandIndex (commandIndex)}
|
||||
{@const Icon = command.icon}
|
||||
{@const isActive =
|
||||
selectedGroupIndex === groupIndex && selectedCommandIndex === commandIndex}
|
||||
<button
|
||||
id={`${groupIndex}-${commandIndex}`}
|
||||
class="edra-slash-command-list-item"
|
||||
class:active={isActive}
|
||||
onclick={() => selectItem(groupIndex, commandIndex)}
|
||||
>
|
||||
<Icon class="edra-toolbar-icon" />
|
||||
<span>{command.tooltip}</span>
|
||||
</button>
|
||||
{/each}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
<script lang="ts">
|
||||
import type { EdraToolBarCommands } from '../../commands/types.js';
|
||||
import { type Editor } from '@tiptap/core';
|
||||
|
||||
interface Props {
|
||||
editor: Editor;
|
||||
command: EdraToolBarCommands;
|
||||
}
|
||||
|
||||
const { editor, command }: Props = $props();
|
||||
</script>
|
||||
|
||||
{#snippet ToolBarIcon({ command, editor }: Props)}
|
||||
{@const Icon = command.icon}
|
||||
<button
|
||||
class="edra-command-button"
|
||||
class:active={command.isActive?.(editor)}
|
||||
onclick={() => command.onClick?.(editor)}
|
||||
disabled={command.clickable ? !command.clickable(editor) : false}
|
||||
title={`${command.tooltip} ${command.shortCut}`}
|
||||
>
|
||||
<Icon class="edra-toolbar-icon" />
|
||||
</button>
|
||||
{/snippet}
|
||||
|
||||
{@render ToolBarIcon({ command, editor })}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
<script lang="ts">
|
||||
import type { NodeViewProps } from '@tiptap/core';
|
||||
import MediaExtended from './MediaExtended.svelte';
|
||||
|
||||
const { ...rest }: NodeViewProps = $props();
|
||||
|
||||
let mediaRef = $state<HTMLElement>();
|
||||
</script>
|
||||
|
||||
<MediaExtended bind:mediaRef {...rest}>
|
||||
{@const node = rest.node}
|
||||
<video bind:this={mediaRef} src={node.attrs.src} controls title={node.attrs.title}>
|
||||
<track kind="captions" />
|
||||
</video>
|
||||
</MediaExtended>
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
<script lang="ts">
|
||||
import MediaPlaceHolder from '../../components/MediaPlaceHolder.svelte';
|
||||
import type { NodeViewProps } from '@tiptap/core';
|
||||
|
||||
const { editor }: NodeViewProps = $props();
|
||||
import Video from '@lucide/svelte/icons/video';
|
||||
|
||||
function handleClick() {
|
||||
const videoUrl = prompt('Please enter the video URL');
|
||||
if (videoUrl) {
|
||||
editor.chain().focus().setVideo(videoUrl).run();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<MediaPlaceHolder
|
||||
class="edra-media-placeholder-wrapper"
|
||||
icon={Video}
|
||||
title="Insert a video"
|
||||
onClick={handleClick}
|
||||
/>
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
<script lang="ts">
|
||||
import { Editor } from '@tiptap/core';
|
||||
|
||||
interface Props {
|
||||
editor: Editor;
|
||||
}
|
||||
|
||||
const { editor }: Props = $props();
|
||||
|
||||
const FONT_SIZE = [
|
||||
{ label: 'Tiny', value: '0.7rem' },
|
||||
{ label: 'Smaller', value: '0.75rem' },
|
||||
{ label: 'Small', value: '0.9rem' },
|
||||
{ label: 'Default', value: '' },
|
||||
{ label: 'Large', value: '1.25rem' },
|
||||
{ label: 'Extra Large', value: '1.5rem' }
|
||||
];
|
||||
|
||||
let currentSize = $derived.by(() => editor.getAttributes('textStyle').fontSize || '');
|
||||
</script>
|
||||
|
||||
<select
|
||||
value={currentSize}
|
||||
onchange={(e) => {
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.setFontSize((e.target as HTMLSelectElement).value)
|
||||
.run();
|
||||
}}
|
||||
title="Font Size"
|
||||
>
|
||||
{#each FONT_SIZE as fontSize (fontSize)}
|
||||
<option value={fontSize.value} label={fontSize.label.split(' ')[0]}></option>
|
||||
{/each}
|
||||
</select>
|
||||
|
||||
<style>
|
||||
select {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
background-color: var(--edra-button-bg-color);
|
||||
border-radius: var(--edra-button-border-radius);
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease-in-out;
|
||||
padding: var(--edra-button-padding);
|
||||
min-width: fit;
|
||||
min-height: full;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
<script lang="ts">
|
||||
import type { Editor } from '@tiptap/core';
|
||||
|
||||
interface Props {
|
||||
editor: Editor;
|
||||
}
|
||||
const { editor }: Props = $props();
|
||||
|
||||
const colors = [
|
||||
{ label: 'Default', value: '' },
|
||||
{ label: 'Blue', value: '#0000FF' },
|
||||
{ label: 'Brown', value: '#A52A2A' },
|
||||
{ label: 'Green', value: '#008000' },
|
||||
{ label: 'Grey', value: '#808080' },
|
||||
{ label: 'Orange', value: '#FFA500' },
|
||||
{ label: 'Pink', value: '#FFC0CB' },
|
||||
{ label: 'Purple', value: '#800080' },
|
||||
{ label: 'Red', value: '#FF0000' },
|
||||
{ label: 'Yellow', value: '#FFFF00' }
|
||||
];
|
||||
|
||||
const currentColor = $derived.by(() => editor.getAttributes('textStyle').color ?? '');
|
||||
const currentHighlight = $derived.by(() => editor.getAttributes('highlight').color ?? '');
|
||||
</script>
|
||||
|
||||
<select
|
||||
value={currentColor}
|
||||
onchange={(e) => {
|
||||
const color = (e.target as HTMLSelectElement).value;
|
||||
editor.chain().focus().setColor(color).run();
|
||||
}}
|
||||
style={`color: ${currentColor}`}
|
||||
title="Text Color"
|
||||
>
|
||||
<option value="" label="Default"></option>
|
||||
{#each colors as color (color)}
|
||||
<option value={color.value} label={color.label}></option>
|
||||
{/each}
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={currentHighlight}
|
||||
onchange={(e) => {
|
||||
const color = (e.target as HTMLSelectElement).value;
|
||||
editor.chain().focus().setHighlight({ color }).run();
|
||||
}}
|
||||
style={`background-color: ${currentHighlight}50`}
|
||||
title="Hightlight Color"
|
||||
>
|
||||
<option value="" label="Default"></option>
|
||||
{#each colors as color (color)}
|
||||
<option value={color.value} label={color.label}>A</option>
|
||||
{/each}
|
||||
</select>
|
||||
|
||||
<style>
|
||||
select {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
background-color: var(--edra-button-bg-color);
|
||||
border-radius: var(--edra-button-border-radius);
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease-in-out;
|
||||
padding: var(--edra-button-padding);
|
||||
min-width: fit;
|
||||
min-height: var(--edra-button-size);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,120 @@
|
|||
<script lang="ts">
|
||||
import type { Editor } from '@tiptap/core';
|
||||
import ArrowLeft from '@lucide/svelte/icons/arrow-left';
|
||||
import ArrowRight from '@lucide/svelte/icons/arrow-right';
|
||||
import CaseSensitive from '@lucide/svelte/icons/case-sensitive';
|
||||
import Replace from '@lucide/svelte/icons/replace';
|
||||
import ReplaceAll from '@lucide/svelte/icons/replace-all';
|
||||
import Search from '@lucide/svelte/icons/search';
|
||||
|
||||
interface Props {
|
||||
editor: Editor;
|
||||
show: boolean;
|
||||
}
|
||||
|
||||
let { editor, show = $bindable(false) }: Props = $props();
|
||||
|
||||
let searchText = $state('');
|
||||
let replaceText = $state('');
|
||||
let caseSensitive = $state(false);
|
||||
|
||||
let searchIndex = $derived(editor.storage?.searchAndReplace?.resultIndex);
|
||||
let searchCount = $derived(editor.storage?.searchAndReplace?.results.length);
|
||||
|
||||
function updateSearchTerm(clearIndex: boolean = false) {
|
||||
if (clearIndex) editor.commands.resetIndex();
|
||||
|
||||
editor.commands.setSearchTerm(searchText);
|
||||
editor.commands.setReplaceTerm(replaceText);
|
||||
editor.commands.setCaseSensitive(caseSensitive);
|
||||
}
|
||||
|
||||
function goToSelection() {
|
||||
const { results, resultIndex } = editor.storage.searchAndReplace;
|
||||
const position = results[resultIndex];
|
||||
if (!position) return;
|
||||
editor.commands.setTextSelection(position);
|
||||
const { node } = editor.view.domAtPos(editor.state.selection.anchor);
|
||||
if (node instanceof HTMLElement) node.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
|
||||
function replace() {
|
||||
editor.commands.replace();
|
||||
goToSelection();
|
||||
}
|
||||
|
||||
const next = () => {
|
||||
editor.commands.nextSearchResult();
|
||||
goToSelection();
|
||||
};
|
||||
|
||||
const previous = () => {
|
||||
editor.commands.previousSearchResult();
|
||||
goToSelection();
|
||||
};
|
||||
|
||||
const clear = () => {
|
||||
searchText = '';
|
||||
replaceText = '';
|
||||
caseSensitive = false;
|
||||
editor.commands.resetIndex();
|
||||
};
|
||||
|
||||
const replaceAll = () => editor.commands.replaceAll();
|
||||
</script>
|
||||
|
||||
<div class="edra-search-and-replace">
|
||||
<button
|
||||
class="edra-command-button"
|
||||
onclick={() => {
|
||||
show = !show;
|
||||
clear();
|
||||
updateSearchTerm();
|
||||
}}
|
||||
title={show ? 'Go Back' : 'Search and Replace'}
|
||||
>
|
||||
{#if show}
|
||||
<ArrowLeft class="edra-toolbar-icon" />
|
||||
{:else}
|
||||
<Search class="edra-toolbar-icon" />
|
||||
{/if}
|
||||
</button>
|
||||
{#if show}
|
||||
<div class="edra-search-and-replace-content">
|
||||
<input placeholder="Search..." bind:value={searchText} oninput={() => updateSearchTerm()} />
|
||||
<span>{searchCount > 0 ? searchIndex + 1 : 0}/{searchCount}</span>
|
||||
<button
|
||||
class="edra-command-button"
|
||||
class:active={caseSensitive}
|
||||
onclick={() => {
|
||||
caseSensitive = !caseSensitive;
|
||||
updateSearchTerm();
|
||||
}}
|
||||
title="Case Sensitive"
|
||||
>
|
||||
<CaseSensitive class="edra-toolbar-icon" />
|
||||
</button>
|
||||
<button class="edra-command-button" onclick={previous} title="Previous">
|
||||
<ArrowLeft class="edra-toolbar-icon" />
|
||||
</button>
|
||||
<button class="edra-command-button" onclick={next} title="Next">
|
||||
<ArrowRight class="edra-toolbar-icon" />
|
||||
</button>
|
||||
<span class="separator"></span>
|
||||
|
||||
<input placeholder="Replace..." bind:value={replaceText} oninput={() => updateSearchTerm()} />
|
||||
<button class="edra-command-button" onclick={replace} title="Replace">
|
||||
<Replace class="edra-toolbar-icon" />
|
||||
</button>
|
||||
<button class="edra-command-button" onclick={replaceAll} title="Replace All">
|
||||
<ReplaceAll class="edra-toolbar-icon" />
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.separator {
|
||||
width: 1rem;
|
||||
}
|
||||
</style>
|
||||
109
src/lib/components/edra/headless/editor.svelte
Normal file
109
src/lib/components/edra/headless/editor.svelte
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
<script lang="ts">
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import type { EdraEditorProps } from '../types.js';
|
||||
import initEditor from '../editor.js';
|
||||
import { focusEditor } from '../utils.js';
|
||||
import '../editor.css';
|
||||
import './style.css';
|
||||
import '../onedark.css';
|
||||
import { ImagePlaceholder } from '../extensions/image/ImagePlaceholder.js';
|
||||
import ImagePlaceholderComp from './components/ImagePlaceholder.svelte';
|
||||
import { ImageExtended } from '../extensions/image/ImageExtended.js';
|
||||
import ImageExtendedComp from './components/ImageExtended.svelte';
|
||||
import { VideoPlaceholder } from '../extensions/video/VideoPlaceholder.js';
|
||||
import VideoPlaceHolderComp from './components/VideoPlaceholder.svelte';
|
||||
import { VideoExtended } from '../extensions/video/VideoExtended.js';
|
||||
import VideoExtendedComp from './components/VideoExtended.svelte';
|
||||
import { AudioPlaceholder } from '../extensions/audio/AudioPlaceholder.js';
|
||||
import { AudioExtended } from '../extensions/audio/AudiExtended.js';
|
||||
import AudioPlaceHolderComp from './components/AudioPlaceHolder.svelte';
|
||||
import AudioExtendedComp from './components/AudioExtended.svelte';
|
||||
import { IFramePlaceholder } from '../extensions/iframe/IFramePlaceholder.js';
|
||||
import { IFrameExtended } from '../extensions/iframe/IFrameExtended.js';
|
||||
import IFramePlaceHolderComp from './components/IFramePlaceHolder.svelte';
|
||||
import IFrameExtendedComp from './components/IFrameExtended.svelte';
|
||||
import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight';
|
||||
import { all, createLowlight } from 'lowlight';
|
||||
import { SvelteNodeViewRenderer } from 'svelte-tiptap';
|
||||
import CodeBlock from './components/CodeBlock.svelte';
|
||||
import TableCol from './menus/TableCol.svelte';
|
||||
import TableRow from './menus/TableRow.svelte';
|
||||
import Link from './menus/Link.svelte';
|
||||
import slashcommand from '../extensions/slash-command/slashcommand.js';
|
||||
import SlashCommandList from './components/SlashCommandList.svelte';
|
||||
|
||||
const lowlight = createLowlight(all);
|
||||
|
||||
/**
|
||||
* Bind the element to the editor
|
||||
*/
|
||||
let element = $state<HTMLElement>();
|
||||
let {
|
||||
editor = $bindable(),
|
||||
editable = true,
|
||||
content,
|
||||
onUpdate,
|
||||
autofocus = false,
|
||||
class: className
|
||||
}: EdraEditorProps = $props();
|
||||
|
||||
onMount(() => {
|
||||
editor = initEditor(
|
||||
element,
|
||||
content,
|
||||
[
|
||||
CodeBlockLowlight.configure({
|
||||
lowlight
|
||||
}).extend({
|
||||
addNodeView() {
|
||||
return SvelteNodeViewRenderer(CodeBlock);
|
||||
}
|
||||
}),
|
||||
ImagePlaceholder(ImagePlaceholderComp),
|
||||
ImageExtended(ImageExtendedComp),
|
||||
VideoPlaceholder(VideoPlaceHolderComp),
|
||||
VideoExtended(VideoExtendedComp),
|
||||
AudioPlaceholder(AudioPlaceHolderComp),
|
||||
AudioExtended(AudioExtendedComp),
|
||||
IFramePlaceholder(IFramePlaceHolderComp),
|
||||
IFrameExtended(IFrameExtendedComp),
|
||||
slashcommand(SlashCommandList)
|
||||
],
|
||||
{
|
||||
onUpdate,
|
||||
onTransaction(props) {
|
||||
// Only update if editor instance actually changed
|
||||
// The old pattern (editor = undefined; editor = props.editor) was a Svelte 4
|
||||
// workaround that causes infinite loops in Svelte 5's fine-grained reactivity
|
||||
if (editor !== props.editor) {
|
||||
editor = props.editor;
|
||||
}
|
||||
},
|
||||
editable,
|
||||
autofocus
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (editor) editor.destroy();
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if editor && !editor.isDestroyed}
|
||||
<Link {editor} />
|
||||
<TableCol {editor} />
|
||||
<TableRow {editor} />
|
||||
{/if}
|
||||
<div
|
||||
bind:this={element}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onclick={(event) => focusEditor(editor, event)}
|
||||
onkeydown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
focusEditor(editor, event);
|
||||
}
|
||||
}}
|
||||
class={`edra-editor ${className}`}
|
||||
></div>
|
||||
3
src/lib/components/edra/headless/index.ts
Normal file
3
src/lib/components/edra/headless/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export { default as EdraEditor } from './editor.svelte';
|
||||
export { default as EdraToolBar } from './toolbar.svelte';
|
||||
export { default as EdraBubbleMenu } from './menus/Menu.svelte';
|
||||
44
src/lib/components/edra/headless/menus/Link.svelte
Normal file
44
src/lib/components/edra/headless/menus/Link.svelte
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
<script lang="ts">
|
||||
import type { ShouldShowProps } from '../../types.js';
|
||||
import BubbleMenu from '../../components/BubbleMenu.svelte';
|
||||
import type { Editor } from '@tiptap/core';
|
||||
import Copy from '@lucide/svelte/icons/copy';
|
||||
import Trash from '@lucide/svelte/icons/trash';
|
||||
|
||||
interface Props {
|
||||
editor: Editor;
|
||||
}
|
||||
|
||||
const { editor }: Props = $props();
|
||||
|
||||
let link = $derived.by(() => editor.getAttributes('link').href);
|
||||
</script>
|
||||
|
||||
<BubbleMenu
|
||||
{editor}
|
||||
pluginKey="link-bubble-menu"
|
||||
shouldShow={(props: ShouldShowProps) => {
|
||||
if (!props.editor.isEditable) return false;
|
||||
return props.editor.isActive('link');
|
||||
}}
|
||||
>
|
||||
<a href={link} target="_blank">
|
||||
{link}
|
||||
</a>
|
||||
<button
|
||||
title="Copy Link"
|
||||
class="edra-command-button"
|
||||
onclick={() => {
|
||||
navigator.clipboard.writeText(link);
|
||||
}}
|
||||
>
|
||||
<Copy class="edra-toolbar-icon" />
|
||||
</button>
|
||||
<button
|
||||
class="edra-command-button"
|
||||
title="Remove Link"
|
||||
onclick={() => editor.chain().focus().extendMarkRange('link').unsetLink().run()}
|
||||
>
|
||||
<Trash class="edra-toolbar-icon" />
|
||||
</button>
|
||||
</BubbleMenu>
|
||||
96
src/lib/components/edra/headless/menus/Menu.svelte
Normal file
96
src/lib/components/edra/headless/menus/Menu.svelte
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
<script lang="ts">
|
||||
import commands from '../../commands/toolbar-commands.js';
|
||||
import BubbleMenu from '../../components/BubbleMenu.svelte';
|
||||
import type { EdraToolbarProps, ShouldShowProps } from '../../types.js';
|
||||
|
||||
import { isTextSelection } from '@tiptap/core';
|
||||
import FontSize from '../components/toolbar/FontSize.svelte';
|
||||
import QuickColors from '../components/toolbar/QuickColors.svelte';
|
||||
import ToolBarIcon from '../components/ToolBarIcon.svelte';
|
||||
|
||||
const {
|
||||
editor,
|
||||
class: className,
|
||||
excludedCommands = ['undo-redo', 'headings', 'media', 'lists', 'table']
|
||||
}: EdraToolbarProps = $props();
|
||||
|
||||
const toolbarCommands = Object.keys(commands).filter((key) => !excludedCommands?.includes(key));
|
||||
|
||||
let isDragging = $state(false);
|
||||
|
||||
editor.view.dom.addEventListener('dragstart', () => {
|
||||
isDragging = true;
|
||||
});
|
||||
|
||||
editor.view.dom.addEventListener('drop', () => {
|
||||
isDragging = true;
|
||||
|
||||
// Allow some time for the drop action to complete before re-enabling
|
||||
setTimeout(() => {
|
||||
isDragging = false;
|
||||
}, 100); // Adjust delay if needed
|
||||
});
|
||||
|
||||
function shouldShow(props: ShouldShowProps) {
|
||||
if (!props.editor.isEditable) return false;
|
||||
const { view, editor } = props;
|
||||
if (!view || editor.view.dragging) {
|
||||
return false;
|
||||
}
|
||||
if (editor.isActive('link')) return false;
|
||||
if (editor.isActive('codeBlock')) return false;
|
||||
if (editor.isActive('image-placeholder')) return false;
|
||||
if (editor.isActive('video-placeholder')) return false;
|
||||
if (editor.isActive('audio-placeholder')) return false;
|
||||
if (editor.isActive('iframe-placeholder')) return false;
|
||||
const {
|
||||
state: {
|
||||
doc,
|
||||
selection,
|
||||
selection: { empty, from, to }
|
||||
}
|
||||
} = editor;
|
||||
// check if the selection is a table grip
|
||||
const domAtPos = view.domAtPos(from || 0).node as HTMLElement;
|
||||
const nodeDOM = view.nodeDOM(from || 0) as HTMLElement;
|
||||
const node = nodeDOM || domAtPos;
|
||||
|
||||
if (isTableGripSelected(node)) {
|
||||
return false;
|
||||
}
|
||||
// Sometime check for `empty` is not enough.
|
||||
// Doubleclick an empty paragraph returns a node size of 2.
|
||||
// So we check also for an empty text size.
|
||||
const isEmptyTextBlock = !doc.textBetween(from, to).length && isTextSelection(selection);
|
||||
if (empty || isEmptyTextBlock || !editor.isEditable) {
|
||||
return false;
|
||||
}
|
||||
return !isDragging && !editor.state.selection.empty;
|
||||
}
|
||||
|
||||
const isTableGripSelected = (node: HTMLElement) => {
|
||||
let container = node;
|
||||
while (container && !['TD', 'TH'].includes(container.tagName)) {
|
||||
container = container.parentElement!;
|
||||
}
|
||||
const gripColumn =
|
||||
container && container.querySelector && container.querySelector('a.grip-column.selected');
|
||||
const gripRow =
|
||||
container && container.querySelector && container.querySelector('a.grip-row.selected');
|
||||
if (gripColumn || gripRow) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
</script>
|
||||
|
||||
<BubbleMenu {editor} class={className} pluginKey="link-bubble-menu" {shouldShow}>
|
||||
{#each toolbarCommands.filter((c) => !excludedCommands?.includes(c)) as cmd (cmd)}
|
||||
{@const commandGroup = commands[cmd]}
|
||||
{#each commandGroup as command (command)}
|
||||
<ToolBarIcon {editor} {command} />
|
||||
{/each}
|
||||
{/each}
|
||||
<FontSize {editor} />
|
||||
<QuickColors {editor} />
|
||||
</BubbleMenu>
|
||||
53
src/lib/components/edra/headless/menus/TableCol.svelte
Normal file
53
src/lib/components/edra/headless/menus/TableCol.svelte
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
<script lang="ts">
|
||||
import { type Editor } from '@tiptap/core';
|
||||
import ArrowLeftFromLine from '@lucide/svelte/icons/arrow-left-from-line';
|
||||
import ArrowRightFromLine from '@lucide/svelte/icons/arrow-right-from-line';
|
||||
import Trash from '@lucide/svelte/icons/trash';
|
||||
import type { ShouldShowProps } from '../../types.js';
|
||||
import { isColumnGripSelected } from '../../extensions/table/utils.js';
|
||||
import BubbleMenu from '../../components/BubbleMenu.svelte';
|
||||
interface Props {
|
||||
editor: Editor;
|
||||
}
|
||||
|
||||
let { editor }: Props = $props();
|
||||
</script>
|
||||
|
||||
<BubbleMenu
|
||||
{editor}
|
||||
pluginKey="table-col-menu"
|
||||
shouldShow={(props: ShouldShowProps) => {
|
||||
if (!props.editor.isEditable) return false;
|
||||
if (!props.state) {
|
||||
return false;
|
||||
}
|
||||
return isColumnGripSelected({
|
||||
editor: props.editor,
|
||||
view: props.view,
|
||||
state: props.state,
|
||||
from: props.from
|
||||
});
|
||||
}}
|
||||
>
|
||||
<button
|
||||
class="edra-command-button"
|
||||
title="Add Column After"
|
||||
onclick={() => editor.chain().focus().addColumnAfter().run()}
|
||||
>
|
||||
<ArrowRightFromLine class="edra-toolbar-icon" />
|
||||
</button>
|
||||
<button
|
||||
class="edra-command-button"
|
||||
title="Add Column Before"
|
||||
onclick={() => editor.chain().focus().addColumnBefore().run()}
|
||||
>
|
||||
<ArrowLeftFromLine class="edra-toolbar-icon" />
|
||||
</button>
|
||||
<button
|
||||
class="edra-command-button"
|
||||
title="Delete Column"
|
||||
onclick={() => editor.chain().focus().deleteColumn().run()}
|
||||
>
|
||||
<Trash class="edra-toolbar-icon" />
|
||||
</button>
|
||||
</BubbleMenu>
|
||||
53
src/lib/components/edra/headless/menus/TableRow.svelte
Normal file
53
src/lib/components/edra/headless/menus/TableRow.svelte
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
<script lang="ts">
|
||||
import { type Editor } from '@tiptap/core';
|
||||
import ArrowDownFromLine from '@lucide/svelte/icons/arrow-down-from-line';
|
||||
import ArrowUpFromLine from '@lucide/svelte/icons/arrow-up-from-line';
|
||||
import Trash from '@lucide/svelte/icons/trash';
|
||||
import type { ShouldShowProps } from '../../types.js';
|
||||
import { isRowGripSelected } from '../../extensions/table/utils.js';
|
||||
import BubbleMenu from '../../components/BubbleMenu.svelte';
|
||||
interface Props {
|
||||
editor: Editor;
|
||||
}
|
||||
|
||||
let { editor }: Props = $props();
|
||||
</script>
|
||||
|
||||
<BubbleMenu
|
||||
{editor}
|
||||
pluginKey="table-row-menu"
|
||||
shouldShow={(props: ShouldShowProps) => {
|
||||
if (!props.editor.isEditable) return false;
|
||||
if (!props.state) {
|
||||
return false;
|
||||
}
|
||||
return isRowGripSelected({
|
||||
editor: props.editor,
|
||||
view: props.view,
|
||||
state: props.state,
|
||||
from: props.from
|
||||
});
|
||||
}}
|
||||
>
|
||||
<button
|
||||
class="edra-command-button"
|
||||
title="Add Column After"
|
||||
onclick={() => editor.chain().focus().addRowAfter().run()}
|
||||
>
|
||||
<ArrowDownFromLine class="edra-toolbar-icon" />
|
||||
</button>
|
||||
<button
|
||||
class="edra-command-button"
|
||||
title="Add Column Before"
|
||||
onclick={() => editor.chain().focus().addRowBefore().run()}
|
||||
>
|
||||
<ArrowUpFromLine class="edra-toolbar-icon" />
|
||||
</button>
|
||||
<button
|
||||
class="edra-command-button"
|
||||
title="Delete Column"
|
||||
onclick={() => editor.chain().focus().deleteRow().run()}
|
||||
>
|
||||
<Trash class="edra-toolbar-icon" />
|
||||
</button>
|
||||
</BubbleMenu>
|
||||
340
src/lib/components/edra/headless/style.css
Normal file
340
src/lib/components/edra/headless/style.css
Normal file
|
|
@ -0,0 +1,340 @@
|
|||
:root {
|
||||
/* Color Variables */
|
||||
--edra-border-color: #80808050;
|
||||
--edra-button-bg-color: #80808025;
|
||||
--edra-button-hover-bg-color: #80808075;
|
||||
--edra-button-active-bg-color: #80808090;
|
||||
--edra-icon-color: currentColor; /* Default, can be customized */
|
||||
--edra-separator-color: currentColor; /* Default, can be customized */
|
||||
|
||||
/* Size and Spacing Variables */
|
||||
--edra-gap: 0.25rem;
|
||||
--edra-border-radius: 0.5rem;
|
||||
--edra-button-border-radius: 0.5rem;
|
||||
--edra-padding: 0.5rem;
|
||||
--edra-button-padding: 0.25rem;
|
||||
--edra-button-size: 2rem;
|
||||
--edra-icon-size: 1rem;
|
||||
--edra-separator-width: 0.25rem;
|
||||
}
|
||||
|
||||
/** Editor Styles */
|
||||
:root {
|
||||
--border-color: rgba(128, 128, 128, 0.3);
|
||||
--border-color-hover: rgba(128, 128, 128, 0.5);
|
||||
--blockquote-border: rgba(128, 128, 128, 0.7);
|
||||
--code-color: rgb(255, 68, 0);
|
||||
--code-bg: rgba(128, 128, 128, 0.3);
|
||||
--code-border: rgba(128, 128, 128, 0.4);
|
||||
--table-border: rgba(128, 128, 128, 0.3);
|
||||
--table-bg-selected: rgba(128, 128, 128, 0.1);
|
||||
--table-bg-hover: rgba(128, 128, 128, 0.2);
|
||||
--task-completed-color: rgba(128, 128, 128, 0.7);
|
||||
--code-wrapper-bg: rgba(128, 128, 128, 0.05);
|
||||
--highlight-color: rgba(0, 128, 0, 0.3);
|
||||
--highlight-border: greenyellow;
|
||||
--search-result-bg: yellow;
|
||||
--search-result-current-bg: orange;
|
||||
}
|
||||
|
||||
.edra-editor {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
cursor: auto;
|
||||
outline: none !important;
|
||||
}
|
||||
|
||||
.tiptap,
|
||||
.ProseMirror {
|
||||
outline: none !important;
|
||||
}
|
||||
|
||||
.tiptap a {
|
||||
color: blue;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.edra-command-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
background-color: var(--edra-button-bg-color);
|
||||
border-radius: var(--edra-button-border-radius);
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease-in-out;
|
||||
padding: var(--edra-button-padding);
|
||||
min-width: var(--edra-button-size);
|
||||
min-height: var(--edra-button-size);
|
||||
}
|
||||
|
||||
.edra-command-button:hover {
|
||||
background-color: var(--edra-button-hover-bg-color);
|
||||
}
|
||||
|
||||
.edra-command-button.active {
|
||||
background-color: var(--edra-button-active-bg-color);
|
||||
}
|
||||
|
||||
.edra-toolbar-icon {
|
||||
height: var(--edra-icon-size);
|
||||
width: var(--edra-icon-size);
|
||||
color: var(--edra-icon-color);
|
||||
}
|
||||
|
||||
.edra-media-placeholder-wrapper {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
padding: 1rem;
|
||||
padding-right: 0;
|
||||
margin: 0.5rem 0;
|
||||
background-color: var(--edra-button-bg-color);
|
||||
border-radius: var(--edra-button-border-radius);
|
||||
border: 1px solid var(--edra-border-color);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: start;
|
||||
gap: 1rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.edra-media-placeholder-icon {
|
||||
height: var(--edra-icon-size);
|
||||
width: var(--edra-icon-size);
|
||||
color: var(--edra-icon-color);
|
||||
}
|
||||
|
||||
.edra-media-container {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: 0.5rem;
|
||||
border: 2px solid transparent;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.edra-media-container.selected {
|
||||
border-color: #808080;
|
||||
}
|
||||
|
||||
.edra-media-container.align-left {
|
||||
left: 0;
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.edra-media-container.align-center {
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.edra-media-container.align-right {
|
||||
left: 100%;
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
|
||||
.edra-media-group {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.edra-media-content {
|
||||
margin: 0;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.edra-media-caption {
|
||||
margin: 0.125rem 0;
|
||||
width: 100%;
|
||||
background-color: transparent;
|
||||
text-align: center;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
color: #808080;
|
||||
outline: none;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.edra-media-resize-handle {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
width: 0.5rem;
|
||||
cursor: col-resize;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.edra-media-resize-handle-left {
|
||||
left: 0;
|
||||
justify-content: flex-start;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.edra-media-resize-handle-right {
|
||||
right: 0;
|
||||
justify-content: flex-end;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.edra-media-resize-indicator {
|
||||
z-index: 20;
|
||||
height: 3rem;
|
||||
width: 0.25rem;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #808080;
|
||||
background-color: #808080;
|
||||
opacity: 0;
|
||||
transition: opacity 0.5s;
|
||||
}
|
||||
|
||||
.edra-media-group:hover .edra-media-resize-indicator {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.edra-media-toolbar {
|
||||
position: absolute;
|
||||
right: 16px;
|
||||
top: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #808080;
|
||||
background-color: #80808075;
|
||||
padding: 4px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.edra-media-toolbar-audio {
|
||||
top: -32px;
|
||||
}
|
||||
|
||||
.edra-media-group:hover .edra-media-toolbar,
|
||||
.edra-media-toolbar.visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.edra-toolbar-button {
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
color: currentColor;
|
||||
}
|
||||
|
||||
.edra-toolbar-button:hover {
|
||||
background-color: #80808030;
|
||||
}
|
||||
|
||||
.edra-toolbar-button.active {
|
||||
background-color: #80808080;
|
||||
}
|
||||
|
||||
.edra-destructive {
|
||||
color: red;
|
||||
}
|
||||
|
||||
.bubble-menu-wrapper {
|
||||
z-index: 100;
|
||||
width: fit-content;
|
||||
padding: 0.25rem;
|
||||
border: 1px solid var(--edra-border-color);
|
||||
border-radius: 0.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
background-color: white;
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
html.dark .bubble-menu-wrapper {
|
||||
background-color: black;
|
||||
}
|
||||
|
||||
.bubble-menu-wrapper input {
|
||||
padding: 0.5rem;
|
||||
border: none;
|
||||
max-width: 10rem;
|
||||
background: none;
|
||||
margin-right: 0.5rem;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
input.valid {
|
||||
border: 1px solid green;
|
||||
}
|
||||
input:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
input.invalid {
|
||||
border: 1px solid red;
|
||||
}
|
||||
|
||||
.edra-slash-command-list {
|
||||
margin-bottom: 2rem;
|
||||
max-height: min(80vh, 20rem);
|
||||
width: fit-content;
|
||||
overflow: auto;
|
||||
scroll-behavior: smooth;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid var(--edra-border-color);
|
||||
padding: 0.5rem;
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.edra-slash-command-list-title {
|
||||
margin: 0.5rem;
|
||||
user-select: none;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.025em;
|
||||
}
|
||||
|
||||
.edra-slash-command-list-item {
|
||||
display: flex;
|
||||
height: fit-content;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
background: none;
|
||||
border: none;
|
||||
margin: 0.25rem 0;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
.edra-slash-command-list-item.active {
|
||||
background-color: var(--edra-border-color);
|
||||
}
|
||||
|
||||
.edra-search-and-replace {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--edra-gap);
|
||||
}
|
||||
|
||||
.edra-search-and-replace-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--edra-gap);
|
||||
}
|
||||
|
||||
.edra-search-and-replace-content input {
|
||||
max-width: 10rem;
|
||||
background: none;
|
||||
width: 15rem;
|
||||
border: 1px solid var(--edra-border-color);
|
||||
border-radius: var(--edra-button-border-radius);
|
||||
padding: 0.2rem 0.5rem;
|
||||
}
|
||||
32
src/lib/components/edra/headless/toolbar.svelte
Normal file
32
src/lib/components/edra/headless/toolbar.svelte
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
<script lang="ts">
|
||||
import commands from '../commands/toolbar-commands.js';
|
||||
import type { EdraToolbarProps } from '../types.js';
|
||||
import FontSize from './components/toolbar/FontSize.svelte';
|
||||
import QuickColors from './components/toolbar/QuickColors.svelte';
|
||||
import SearchAndReplace from './components/toolbar/SearchAndReplace.svelte';
|
||||
import ToolBarIcon from './components/ToolBarIcon.svelte';
|
||||
|
||||
const { editor, class: className, excludedCommands, children }: EdraToolbarProps = $props();
|
||||
|
||||
const toolbarCommands = Object.keys(commands).filter((key) => !excludedCommands?.includes(key));
|
||||
|
||||
let show = $state<boolean>(false);
|
||||
</script>
|
||||
|
||||
<div class={`edra-toolbar ${className}`}>
|
||||
{#if children}
|
||||
{@render children()}
|
||||
{:else}
|
||||
{#if !show}
|
||||
{#each toolbarCommands as cmd (cmd)}
|
||||
{@const commandGroup = commands[cmd]}
|
||||
{#each commandGroup as command (command)}
|
||||
<ToolBarIcon {editor} {command} />
|
||||
{/each}
|
||||
{/each}
|
||||
<FontSize {editor} />
|
||||
<QuickColors {editor} />
|
||||
{/if}
|
||||
<SearchAndReplace {editor} bind:show />
|
||||
{/if}
|
||||
</div>
|
||||
176
src/lib/components/edra/onedark.css
Normal file
176
src/lib/components/edra/onedark.css
Normal 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;
|
||||
}
|
||||
}
|
||||
75
src/lib/components/edra/svelte-renderer.ts
Normal file
75
src/lib/components/edra/svelte-renderer.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
import { flushSync, mount, unmount } from 'svelte';
|
||||
import type { Editor, NodeViewProps } from '@tiptap/core';
|
||||
|
||||
interface RendererOptions<P extends Record<string, unknown>> {
|
||||
editor: Editor;
|
||||
props: P;
|
||||
}
|
||||
|
||||
type App = ReturnType<typeof mount>;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
class SvelteRenderer<R = unknown, P extends Record<string, any> = object> {
|
||||
id: string;
|
||||
component: App;
|
||||
editor: Editor;
|
||||
props: P;
|
||||
element: HTMLElement;
|
||||
ref: R | null = null;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
mnt: Record<any, any> | null = null;
|
||||
|
||||
constructor(component: App, { props, editor }: RendererOptions<P>) {
|
||||
this.id = Math.floor(Math.random() * 0xffffffff).toString();
|
||||
this.component = component;
|
||||
this.props = props;
|
||||
this.editor = editor;
|
||||
|
||||
this.element = document.createElement('div');
|
||||
this.element.classList.add('svelte-renderer');
|
||||
|
||||
if (this.editor.isInitialized) {
|
||||
// On first render, we need to flush the render synchronously
|
||||
// Renders afterwards can be async, but this fixes a cursor positioning issue
|
||||
flushSync(() => {
|
||||
this.render();
|
||||
});
|
||||
} else {
|
||||
this.render();
|
||||
}
|
||||
}
|
||||
|
||||
render(): void {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
this.mnt = mount(this.component as any, {
|
||||
target: this.element,
|
||||
props: {
|
||||
props: this.props
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
updateProps(props: Partial<NodeViewProps>): void {
|
||||
Object.assign(this.props, props);
|
||||
this.destroy();
|
||||
this.render();
|
||||
}
|
||||
|
||||
updateAttributes(attributes: Record<string, string>): void {
|
||||
Object.keys(attributes).forEach((key) => {
|
||||
this.element.setAttribute(key, attributes[key]);
|
||||
});
|
||||
this.destroy();
|
||||
this.render();
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
if (this.mnt) {
|
||||
unmount(this.mnt);
|
||||
} else {
|
||||
unmount(this.component);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default SvelteRenderer;
|
||||
30
src/lib/components/edra/types.ts
Normal file
30
src/lib/components/edra/types.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import type { Content, Editor } from '@tiptap/core';
|
||||
import type { EditorState } from '@tiptap/pm/state';
|
||||
import type { EditorView } from '@tiptap/pm/view';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
export interface EdraEditorProps {
|
||||
content?: Content;
|
||||
editable?: boolean;
|
||||
editor?: Editor;
|
||||
autofocus?: boolean;
|
||||
onUpdate?: () => void;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
export interface EdraToolbarProps {
|
||||
editor: Editor;
|
||||
class?: string;
|
||||
excludedCommands?: string[];
|
||||
children?: Snippet<[]>;
|
||||
}
|
||||
|
||||
export interface ShouldShowProps {
|
||||
editor: Editor;
|
||||
element: HTMLElement;
|
||||
view: EditorView;
|
||||
state: EditorState;
|
||||
oldState?: EditorState;
|
||||
from: number;
|
||||
to: number;
|
||||
}
|
||||
119
src/lib/components/edra/utils.ts
Normal file
119
src/lib/components/edra/utils.ts
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
import { browser } from '$app/environment';
|
||||
import type { Editor } from '@tiptap/core';
|
||||
import { Decoration, DecorationSet, type EditorView } from '@tiptap/pm/view';
|
||||
import { Node } from '@tiptap/pm/model';
|
||||
|
||||
/**
|
||||
* Check if the current browser is in mac or not
|
||||
*/
|
||||
export const isMac = browser
|
||||
? navigator.userAgent.includes('Macintosh') || navigator.userAgent.includes('Mac OS X')
|
||||
: false;
|
||||
|
||||
/**
|
||||
* Function to handle paste event of an image
|
||||
* @param editor Editor - editor instance
|
||||
* @param maxSize number - max size of the image to be pasted in MB, default is 2MB
|
||||
*/
|
||||
export function getHandlePaste(editor: Editor, maxSize: number = 2) {
|
||||
return (view: EditorView, event: ClipboardEvent) => {
|
||||
const item = event.clipboardData?.items[0];
|
||||
|
||||
if (item?.type.indexOf('image') !== 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const file = item.getAsFile();
|
||||
if (file === null || file.size === undefined) return;
|
||||
const filesize = (file?.size / 1024 / 1024).toFixed(4);
|
||||
|
||||
if (filesize && Number(filesize) > maxSize) {
|
||||
window.alert(`too large image! filesize: ${filesize} mb`);
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.readAsDataURL(file);
|
||||
// reader.onload = (e) => {
|
||||
// if (e.target?.result) {
|
||||
// editor.commands.setImage({ src: e.target.result as string });
|
||||
// }
|
||||
// };
|
||||
};
|
||||
}
|
||||
|
||||
export const findColors = (doc: Node) => {
|
||||
const hexColor = /(#[0-9a-f]{3,6})\b/gi;
|
||||
const decorations: Decoration[] = [];
|
||||
|
||||
doc.descendants((node, position) => {
|
||||
if (!node.text) {
|
||||
return;
|
||||
}
|
||||
|
||||
Array.from(node.text.matchAll(hexColor)).forEach((match) => {
|
||||
const color = match[0];
|
||||
const index = match.index || 0;
|
||||
const from = position + index;
|
||||
const to = from + color.length;
|
||||
const decoration = Decoration.inline(from, to, {
|
||||
class: 'color',
|
||||
style: `--color: ${color}`
|
||||
});
|
||||
|
||||
decorations.push(decoration);
|
||||
});
|
||||
});
|
||||
|
||||
return DecorationSet.create(doc, decorations);
|
||||
};
|
||||
|
||||
/**
|
||||
* Dupilcate content at the current selection
|
||||
* @param editor Editor instance
|
||||
* @param node Node to be duplicated
|
||||
*/
|
||||
export const duplicateContent = (editor: Editor, node: Node) => {
|
||||
const { view } = editor;
|
||||
const { state } = view;
|
||||
const { selection } = state;
|
||||
|
||||
editor
|
||||
.chain()
|
||||
.insertContentAt(selection.to, node.toJSON(), {
|
||||
updateSelection: true
|
||||
})
|
||||
.focus(selection.to)
|
||||
.run();
|
||||
};
|
||||
|
||||
/**
|
||||
* Sets focus on the editor and moves the cursor to the clicked text position,
|
||||
* defaulting to the end of the document if the click is outside any text.
|
||||
*
|
||||
* @param editor - Editor instance
|
||||
* @param event - Optional MouseEvent or KeyboardEvent triggering the focus
|
||||
*/
|
||||
export function focusEditor(editor: Editor | undefined, event?: MouseEvent | KeyboardEvent) {
|
||||
if (!editor) return;
|
||||
// Check if there is a text selection already (i.e. a non-empty selection)
|
||||
const selection = window.getSelection();
|
||||
if (selection && selection.toString().length > 0) {
|
||||
// Focus the editor without modifying selection
|
||||
editor.chain().focus().run();
|
||||
return;
|
||||
}
|
||||
if (event instanceof MouseEvent) {
|
||||
const { clientX, clientY } = event;
|
||||
const pos = editor.view.posAtCoords({ left: clientX, top: clientY })?.pos;
|
||||
if (pos == null) {
|
||||
// If not a valid position, move cursor to the end of the document
|
||||
const endPos = editor.state.doc.content.size;
|
||||
editor.chain().focus().setTextSelection(endPos).run();
|
||||
} else {
|
||||
editor.chain().focus().setTextSelection(pos).run();
|
||||
}
|
||||
} else {
|
||||
editor.chain().focus().run();
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue