@svadmin/editor 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +66 -0
- package/src/components/BubbleMenu.svelte +104 -0
- package/src/components/ColorPicker.svelte +69 -0
- package/src/components/Editor.svelte +223 -0
- package/src/components/FloatingMenu.svelte +77 -0
- package/src/components/ImageUpload.svelte +159 -0
- package/src/components/LinkPopover.svelte +87 -0
- package/src/components/TableMenu.svelte +94 -0
- package/src/components/Toolbar.svelte +312 -0
- package/src/components/ToolbarButton.svelte +27 -0
- package/src/components/ToolbarGroup.svelte +11 -0
- package/src/components/ToolbarSelect.svelte +23 -0
- package/src/editor.svelte.ts +99 -0
- package/src/extensions/index.ts +211 -0
- package/src/extensions/slash-command.ts +150 -0
- package/src/index.ts +65 -0
- package/src/styles/editor.css +697 -0
package/package.json
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@svadmin/editor",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Modern Tiptap-based rich text editor for Svelte 5 — headless, modular, production-ready",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"files": [
|
|
7
|
+
"src/**/*"
|
|
8
|
+
],
|
|
9
|
+
"main": "src/index.ts",
|
|
10
|
+
"types": "src/index.ts",
|
|
11
|
+
"exports": {
|
|
12
|
+
".": {
|
|
13
|
+
"types": "./src/index.ts",
|
|
14
|
+
"default": "./src/index.ts"
|
|
15
|
+
},
|
|
16
|
+
"./components/*": "./src/components/*",
|
|
17
|
+
"./editor.css": "./src/styles/editor.css"
|
|
18
|
+
},
|
|
19
|
+
"peerDependencies": {
|
|
20
|
+
"svelte": "^5.0.0",
|
|
21
|
+
"@svadmin/core": "^0.5.14"
|
|
22
|
+
},
|
|
23
|
+
"devDependencies": {
|
|
24
|
+
"@svadmin/core": "^0.5.14"
|
|
25
|
+
},
|
|
26
|
+
"dependencies": {
|
|
27
|
+
"@tiptap/core": "^2.12.0",
|
|
28
|
+
"@tiptap/pm": "^2.12.0",
|
|
29
|
+
"@tiptap/starter-kit": "^2.12.0",
|
|
30
|
+
"@tiptap/extension-bubble-menu": "^2.12.0",
|
|
31
|
+
"@tiptap/extension-character-count": "^2.12.0",
|
|
32
|
+
"@tiptap/extension-code-block-lowlight": "^2.12.0",
|
|
33
|
+
"@tiptap/extension-color": "^2.12.0",
|
|
34
|
+
"@tiptap/extension-floating-menu": "^2.12.0",
|
|
35
|
+
"@tiptap/extension-highlight": "^2.12.0",
|
|
36
|
+
"@tiptap/extension-image": "^2.12.0",
|
|
37
|
+
"@tiptap/extension-link": "^2.12.0",
|
|
38
|
+
"@tiptap/extension-placeholder": "^2.12.0",
|
|
39
|
+
"@tiptap/extension-subscript": "^2.12.0",
|
|
40
|
+
"@tiptap/extension-superscript": "^2.12.0",
|
|
41
|
+
"@tiptap/extension-table": "^2.12.0",
|
|
42
|
+
"@tiptap/extension-table-cell": "^2.12.0",
|
|
43
|
+
"@tiptap/extension-table-header": "^2.12.0",
|
|
44
|
+
"@tiptap/extension-table-row": "^2.12.0",
|
|
45
|
+
"@tiptap/extension-task-item": "^2.12.0",
|
|
46
|
+
"@tiptap/extension-task-list": "^2.12.0",
|
|
47
|
+
"@tiptap/extension-text-align": "^2.12.0",
|
|
48
|
+
"@tiptap/extension-text-style": "^2.12.0",
|
|
49
|
+
"@tiptap/extension-typography": "^2.12.0",
|
|
50
|
+
"@tiptap/extension-underline": "^2.12.0",
|
|
51
|
+
"@tiptap/suggestion": "^2.12.0",
|
|
52
|
+
"lowlight": "^3.3.0",
|
|
53
|
+
"lucide-svelte": "^0.577.0"
|
|
54
|
+
},
|
|
55
|
+
"license": "MIT",
|
|
56
|
+
"publishConfig": {
|
|
57
|
+
"access": "public",
|
|
58
|
+
"registry": "https://registry.npmjs.org/"
|
|
59
|
+
},
|
|
60
|
+
"author": "zuohuadong",
|
|
61
|
+
"repository": {
|
|
62
|
+
"type": "git",
|
|
63
|
+
"url": "https://github.com/zuohuadong/svadmin.git",
|
|
64
|
+
"directory": "packages/editor"
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { Editor } from '@tiptap/core';
|
|
3
|
+
import { BubbleMenu as TiptapBubbleMenu } from '@tiptap/extension-bubble-menu';
|
|
4
|
+
import ToolbarButton from './ToolbarButton.svelte';
|
|
5
|
+
import {
|
|
6
|
+
Bold, Italic, Underline as UnderlineIcon, Strikethrough,
|
|
7
|
+
Code, Link as LinkIcon, Highlighter,
|
|
8
|
+
} from 'lucide-svelte';
|
|
9
|
+
import { t } from '@svadmin/core';
|
|
10
|
+
|
|
11
|
+
let { editor } = $props<{
|
|
12
|
+
editor: Editor;
|
|
13
|
+
}>();
|
|
14
|
+
|
|
15
|
+
let menuElement: HTMLDivElement | undefined = $state();
|
|
16
|
+
|
|
17
|
+
let txn = $state(0);
|
|
18
|
+
$effect(() => {
|
|
19
|
+
const handler = () => { txn++; };
|
|
20
|
+
editor.on('transaction', handler);
|
|
21
|
+
return () => { editor.off('transaction', handler); };
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const isBold = $derived((void txn, editor.isActive('bold')));
|
|
25
|
+
const isItalic = $derived((void txn, editor.isActive('italic')));
|
|
26
|
+
const isUnderline = $derived((void txn, editor.isActive('underline')));
|
|
27
|
+
const isStrike = $derived((void txn, editor.isActive('strike')));
|
|
28
|
+
const isCode = $derived((void txn, editor.isActive('code')));
|
|
29
|
+
const isLink = $derived((void txn, editor.isActive('link')));
|
|
30
|
+
|
|
31
|
+
// Register the bubble menu extension
|
|
32
|
+
$effect(() => {
|
|
33
|
+
if (!menuElement) return;
|
|
34
|
+
|
|
35
|
+
// Check if BubbleMenu is already registered
|
|
36
|
+
const existing = editor.extensionManager.extensions.find(
|
|
37
|
+
(e) => e.name === 'bubbleMenu'
|
|
38
|
+
);
|
|
39
|
+
if (existing) return;
|
|
40
|
+
|
|
41
|
+
// We need to register the extension dynamically
|
|
42
|
+
// Tiptap handles this through the plugin system
|
|
43
|
+
const plugin = TiptapBubbleMenu.configure({
|
|
44
|
+
element: menuElement,
|
|
45
|
+
tippyOptions: {
|
|
46
|
+
duration: 150,
|
|
47
|
+
placement: 'top',
|
|
48
|
+
animation: 'shift-away',
|
|
49
|
+
},
|
|
50
|
+
shouldShow: ({ editor: e, state }) => {
|
|
51
|
+
const { selection } = state;
|
|
52
|
+
const { empty } = selection;
|
|
53
|
+
// Don't show in code blocks or empty selections
|
|
54
|
+
if (empty || e.isActive('codeBlock')) return false;
|
|
55
|
+
return true;
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
editor.registerPlugin(plugin.options.plugin(plugin.options));
|
|
60
|
+
return () => {
|
|
61
|
+
// cleanup handled by editor destroy
|
|
62
|
+
};
|
|
63
|
+
});
|
|
64
|
+
</script>
|
|
65
|
+
|
|
66
|
+
<div class="svadmin-bubble-menu" bind:this={menuElement} style="visibility: hidden;">
|
|
67
|
+
<ToolbarButton title={t('editor.bold')} active={isBold} onclick={() => editor.chain().focus().toggleBold().run()}>
|
|
68
|
+
<Bold />
|
|
69
|
+
</ToolbarButton>
|
|
70
|
+
<ToolbarButton title={t('editor.italic')} active={isItalic} onclick={() => editor.chain().focus().toggleItalic().run()}>
|
|
71
|
+
<Italic />
|
|
72
|
+
</ToolbarButton>
|
|
73
|
+
<ToolbarButton title={t('editor.underline')} active={isUnderline} onclick={() => editor.chain().focus().toggleUnderline().run()}>
|
|
74
|
+
<UnderlineIcon />
|
|
75
|
+
</ToolbarButton>
|
|
76
|
+
<ToolbarButton title={t('editor.strikethrough')} active={isStrike} onclick={() => editor.chain().focus().toggleStrike().run()}>
|
|
77
|
+
<Strikethrough />
|
|
78
|
+
</ToolbarButton>
|
|
79
|
+
<ToolbarButton title={t('editor.code')} active={isCode} onclick={() => editor.chain().focus().toggleCode().run()}>
|
|
80
|
+
<Code />
|
|
81
|
+
</ToolbarButton>
|
|
82
|
+
<ToolbarButton
|
|
83
|
+
title={t('editor.link')}
|
|
84
|
+
active={isLink}
|
|
85
|
+
onclick={() => {
|
|
86
|
+
if (isLink) {
|
|
87
|
+
editor.chain().focus().unsetLink().run();
|
|
88
|
+
} else {
|
|
89
|
+
const url = window.prompt('Enter URL:');
|
|
90
|
+
if (url) {
|
|
91
|
+
editor.chain().focus().extendMarkRange('link').setLink({ href: url }).run();
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}}
|
|
95
|
+
>
|
|
96
|
+
<LinkIcon />
|
|
97
|
+
</ToolbarButton>
|
|
98
|
+
<ToolbarButton
|
|
99
|
+
title="Highlight"
|
|
100
|
+
onclick={() => editor.chain().focus().toggleHighlight().run()}
|
|
101
|
+
>
|
|
102
|
+
<Highlighter />
|
|
103
|
+
</ToolbarButton>
|
|
104
|
+
</div>
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
let { onselect, onclose } = $props<{
|
|
3
|
+
onselect: (color: string) => void;
|
|
4
|
+
onclose?: () => void;
|
|
5
|
+
}>();
|
|
6
|
+
|
|
7
|
+
const colors = [
|
|
8
|
+
// Row 1: Reds
|
|
9
|
+
'#000000', '#434343', '#666666', '#999999',
|
|
10
|
+
'#b7b7b7', '#cccccc', '#d9d9d9', '#ffffff',
|
|
11
|
+
// Row 2: Saturated
|
|
12
|
+
'#e06666', '#f6b26b', '#ffd966', '#93c47d',
|
|
13
|
+
'#76a5af', '#6fa8dc', '#8e7cc3', '#c27ba0',
|
|
14
|
+
// Row 3: Deep
|
|
15
|
+
'#cc0000', '#e69138', '#f1c232', '#6aa84f',
|
|
16
|
+
'#45818e', '#3d85c6', '#674ea7', '#a64d79',
|
|
17
|
+
// Row 4: Vivid
|
|
18
|
+
'#ff0000', '#ff9900', '#ffff00', '#00ff00',
|
|
19
|
+
'#00ffff', '#0000ff', '#9900ff', '#ff00ff',
|
|
20
|
+
// Row 5: Dark
|
|
21
|
+
'#7f0000', '#783f04', '#7f6000', '#274e13',
|
|
22
|
+
'#0c343d', '#073763', '#20124d', '#4c1130',
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
let popover: HTMLDivElement | undefined = $state();
|
|
26
|
+
|
|
27
|
+
function handleClickOutside(e: MouseEvent) {
|
|
28
|
+
if (popover && !popover.contains(e.target as Node)) {
|
|
29
|
+
onclose?.();
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
$effect(() => {
|
|
34
|
+
document.addEventListener('mousedown', handleClickOutside);
|
|
35
|
+
return () => document.removeEventListener('mousedown', handleClickOutside);
|
|
36
|
+
});
|
|
37
|
+
</script>
|
|
38
|
+
|
|
39
|
+
<div class="svadmin-color-picker" bind:this={popover}>
|
|
40
|
+
<div class="svadmin-color-grid">
|
|
41
|
+
{#each colors as color}
|
|
42
|
+
<button
|
|
43
|
+
type="button"
|
|
44
|
+
class="svadmin-color-swatch"
|
|
45
|
+
style="background-color: {color}"
|
|
46
|
+
title={color}
|
|
47
|
+
onclick={() => onselect(color)}
|
|
48
|
+
aria-label="Color {color}"
|
|
49
|
+
></button>
|
|
50
|
+
{/each}
|
|
51
|
+
</div>
|
|
52
|
+
<div style="margin-top: 6px; display: flex; gap: 4px; align-items: center;">
|
|
53
|
+
<input
|
|
54
|
+
type="color"
|
|
55
|
+
style="width: 28px; height: 28px; border: none; padding: 0; cursor: pointer; border-radius: 4px;"
|
|
56
|
+
oninput={(e) => onselect((e.target as HTMLInputElement).value)}
|
|
57
|
+
aria-label="Custom color"
|
|
58
|
+
/>
|
|
59
|
+
<button
|
|
60
|
+
type="button"
|
|
61
|
+
class="svadmin-toolbar-btn"
|
|
62
|
+
style="font-size: 11px; width: auto; padding: 0 8px;"
|
|
63
|
+
title="Remove color"
|
|
64
|
+
onclick={() => onselect('')}
|
|
65
|
+
>
|
|
66
|
+
Reset
|
|
67
|
+
</button>
|
|
68
|
+
</div>
|
|
69
|
+
</div>
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { Editor } from '@tiptap/core';
|
|
3
|
+
import { onDestroy } from 'svelte';
|
|
4
|
+
import { fullPreset, minimalPreset, prosePreset, type EditorPresetOptions } from '../extensions/index.js';
|
|
5
|
+
import type { AnyExtension } from '@tiptap/core';
|
|
6
|
+
import Toolbar from './Toolbar.svelte';
|
|
7
|
+
import BubbleMenuComp from './BubbleMenu.svelte';
|
|
8
|
+
import FloatingMenuComp from './FloatingMenu.svelte';
|
|
9
|
+
import { t } from '@svadmin/core';
|
|
10
|
+
import '../styles/editor.css';
|
|
11
|
+
|
|
12
|
+
interface Props {
|
|
13
|
+
/** HTML string content (bindable) */
|
|
14
|
+
value?: string;
|
|
15
|
+
/** Placeholder text */
|
|
16
|
+
placeholder?: string;
|
|
17
|
+
/** Whether the editor is editable */
|
|
18
|
+
editable?: boolean;
|
|
19
|
+
/** Autofocus on mount */
|
|
20
|
+
autofocus?: boolean | 'start' | 'end';
|
|
21
|
+
/** Max character count */
|
|
22
|
+
maxLength?: number;
|
|
23
|
+
/** Show toolbar */
|
|
24
|
+
showToolbar?: boolean;
|
|
25
|
+
/** Show bubble menu on text selection */
|
|
26
|
+
showBubbleMenu?: boolean;
|
|
27
|
+
/** Show floating menu on empty lines */
|
|
28
|
+
showFloatingMenu?: boolean;
|
|
29
|
+
/** Show character count in footer */
|
|
30
|
+
showCharCount?: boolean;
|
|
31
|
+
/** Extension preset */
|
|
32
|
+
preset?: 'full' | 'minimal' | 'prose';
|
|
33
|
+
/** Custom extensions (overrides preset) */
|
|
34
|
+
extensions?: AnyExtension[];
|
|
35
|
+
/** Image upload handler */
|
|
36
|
+
onUpload?: (file: File) => Promise<string>;
|
|
37
|
+
/** Called on every content change */
|
|
38
|
+
onchange?: (html: string) => void;
|
|
39
|
+
/** Additional CSS class for the wrapper */
|
|
40
|
+
class?: string;
|
|
41
|
+
/** Min height for the editor content area */
|
|
42
|
+
minHeight?: string;
|
|
43
|
+
/** Max height for the editor content area */
|
|
44
|
+
maxHeight?: string;
|
|
45
|
+
/** lowlight instance for syntax-highlighted code blocks */
|
|
46
|
+
lowlight?: unknown;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
let {
|
|
50
|
+
value = $bindable(''),
|
|
51
|
+
placeholder = t('editor.startWriting'),
|
|
52
|
+
editable = true,
|
|
53
|
+
autofocus = false,
|
|
54
|
+
maxLength = 0,
|
|
55
|
+
showToolbar = true,
|
|
56
|
+
showBubbleMenu = true,
|
|
57
|
+
showFloatingMenu = true,
|
|
58
|
+
showCharCount = true,
|
|
59
|
+
preset = 'full',
|
|
60
|
+
extensions: customExtensions,
|
|
61
|
+
onUpload,
|
|
62
|
+
onchange,
|
|
63
|
+
class: className = '',
|
|
64
|
+
minHeight = '200px',
|
|
65
|
+
maxHeight = '600px',
|
|
66
|
+
lowlight,
|
|
67
|
+
}: Props = $props();
|
|
68
|
+
|
|
69
|
+
let editorElement: HTMLDivElement | undefined = $state();
|
|
70
|
+
let editor: Editor | null = $state(null);
|
|
71
|
+
|
|
72
|
+
// Track whether we're updating externally to prevent loops
|
|
73
|
+
let isUpdatingFromExternal = false;
|
|
74
|
+
let isUpdatingFromEditor = false;
|
|
75
|
+
|
|
76
|
+
// Build extensions based on preset
|
|
77
|
+
function buildExtensions(): AnyExtension[] {
|
|
78
|
+
if (customExtensions) return customExtensions;
|
|
79
|
+
|
|
80
|
+
const opts: EditorPresetOptions = {
|
|
81
|
+
placeholder,
|
|
82
|
+
maxLength,
|
|
83
|
+
lowlight,
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
switch (preset) {
|
|
87
|
+
case 'minimal': return minimalPreset(opts);
|
|
88
|
+
case 'prose': return prosePreset(opts);
|
|
89
|
+
case 'full':
|
|
90
|
+
default: return fullPreset(opts);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Create editor when element is available
|
|
95
|
+
$effect(() => {
|
|
96
|
+
if (!editorElement) return;
|
|
97
|
+
|
|
98
|
+
const ext = buildExtensions();
|
|
99
|
+
|
|
100
|
+
editor = new Editor({
|
|
101
|
+
element: editorElement,
|
|
102
|
+
extensions: ext,
|
|
103
|
+
content: value || '',
|
|
104
|
+
editable,
|
|
105
|
+
autofocus,
|
|
106
|
+
editorProps: {
|
|
107
|
+
attributes: {
|
|
108
|
+
class: 'tiptap',
|
|
109
|
+
},
|
|
110
|
+
// Handle image drop
|
|
111
|
+
handleDrop(view, event, slice, moved) {
|
|
112
|
+
if (!moved && event.dataTransfer?.files?.length && onUpload) {
|
|
113
|
+
const files = event.dataTransfer.files;
|
|
114
|
+
for (let i = 0; i < files.length; i++) {
|
|
115
|
+
const file = files[i];
|
|
116
|
+
if (file.type.startsWith('image/')) {
|
|
117
|
+
event.preventDefault();
|
|
118
|
+
onUpload(file).then((url) => {
|
|
119
|
+
if (editor) {
|
|
120
|
+
const { schema } = editor.state;
|
|
121
|
+
const pos = view.posAtCoords({ left: event.clientX, top: event.clientY });
|
|
122
|
+
if (pos) {
|
|
123
|
+
const node = schema.nodes.image.create({ src: url, alt: file.name });
|
|
124
|
+
const transaction = view.state.tr.insert(pos.pos, node);
|
|
125
|
+
view.dispatch(transaction);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
return true;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return false;
|
|
134
|
+
},
|
|
135
|
+
// Handle image paste
|
|
136
|
+
handlePaste(view, event) {
|
|
137
|
+
const items = event.clipboardData?.items;
|
|
138
|
+
if (items && onUpload) {
|
|
139
|
+
for (let i = 0; i < items.length; i++) {
|
|
140
|
+
const item = items[i];
|
|
141
|
+
if (item.type.startsWith('image/')) {
|
|
142
|
+
event.preventDefault();
|
|
143
|
+
const file = item.getAsFile();
|
|
144
|
+
if (file) {
|
|
145
|
+
onUpload(file).then((url) => {
|
|
146
|
+
if (editor) {
|
|
147
|
+
editor.chain().focus().setImage({ src: url, alt: file.name }).run();
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
return true;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return false;
|
|
156
|
+
},
|
|
157
|
+
},
|
|
158
|
+
onUpdate({ editor: e }) {
|
|
159
|
+
if (isUpdatingFromExternal) return;
|
|
160
|
+
isUpdatingFromEditor = true;
|
|
161
|
+
const html = e.getHTML();
|
|
162
|
+
value = html;
|
|
163
|
+
onchange?.(html);
|
|
164
|
+
// Defer reset to allow Svelte to process
|
|
165
|
+
queueMicrotask(() => { isUpdatingFromEditor = false; });
|
|
166
|
+
},
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
return () => {
|
|
170
|
+
editor?.destroy();
|
|
171
|
+
editor = null;
|
|
172
|
+
};
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
// Sync external value changes into the editor
|
|
176
|
+
$effect(() => {
|
|
177
|
+
if (!editor || isUpdatingFromEditor) return;
|
|
178
|
+
const currentHtml = editor.getHTML();
|
|
179
|
+
if (value !== currentHtml) {
|
|
180
|
+
isUpdatingFromExternal = true;
|
|
181
|
+
editor.commands.setContent(value || '', false);
|
|
182
|
+
isUpdatingFromExternal = false;
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
// Sync editable state
|
|
187
|
+
$effect(() => {
|
|
188
|
+
if (editor) {
|
|
189
|
+
editor.setEditable(editable);
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
// Character count
|
|
194
|
+
const charCount = $derived(editor?.storage.characterCount?.characters() ?? 0);
|
|
195
|
+
const wordCount = $derived(editor?.storage.characterCount?.words() ?? 0);
|
|
196
|
+
</script>
|
|
197
|
+
|
|
198
|
+
<div class="svadmin-editor {className}">
|
|
199
|
+
{#if showToolbar && editor}
|
|
200
|
+
<Toolbar {editor} {onUpload} {preset} />
|
|
201
|
+
{/if}
|
|
202
|
+
|
|
203
|
+
<div
|
|
204
|
+
class="svadmin-editor-content"
|
|
205
|
+
style="min-height: {minHeight}; max-height: {maxHeight};"
|
|
206
|
+
bind:this={editorElement}
|
|
207
|
+
></div>
|
|
208
|
+
|
|
209
|
+
{#if editor && showBubbleMenu && preset !== 'minimal'}
|
|
210
|
+
<BubbleMenuComp {editor} />
|
|
211
|
+
{/if}
|
|
212
|
+
|
|
213
|
+
{#if editor && showFloatingMenu && preset !== 'minimal'}
|
|
214
|
+
<FloatingMenuComp {editor} />
|
|
215
|
+
{/if}
|
|
216
|
+
|
|
217
|
+
{#if showCharCount && editor}
|
|
218
|
+
<div class="svadmin-editor-footer">
|
|
219
|
+
<span>{wordCount} words</span>
|
|
220
|
+
<span>{charCount} characters{maxLength ? ` / ${maxLength}` : ''}</span>
|
|
221
|
+
</div>
|
|
222
|
+
{/if}
|
|
223
|
+
</div>
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { Editor } from '@tiptap/core';
|
|
3
|
+
import { FloatingMenu as TiptapFloatingMenu } from '@tiptap/extension-floating-menu';
|
|
4
|
+
import ToolbarButton from './ToolbarButton.svelte';
|
|
5
|
+
import {
|
|
6
|
+
Heading1, Heading2, Heading3,
|
|
7
|
+
List, ListOrdered, ListChecks,
|
|
8
|
+
Quote, Code2, Minus, ImageIcon, TableIcon,
|
|
9
|
+
} from 'lucide-svelte';
|
|
10
|
+
import { t } from '@svadmin/core';
|
|
11
|
+
|
|
12
|
+
let { editor } = $props<{
|
|
13
|
+
editor: Editor;
|
|
14
|
+
}>();
|
|
15
|
+
|
|
16
|
+
let menuElement: HTMLDivElement | undefined = $state();
|
|
17
|
+
|
|
18
|
+
$effect(() => {
|
|
19
|
+
if (!menuElement) return;
|
|
20
|
+
|
|
21
|
+
const existing = editor.extensionManager.extensions.find(
|
|
22
|
+
(e) => e.name === 'floatingMenu'
|
|
23
|
+
);
|
|
24
|
+
if (existing) return;
|
|
25
|
+
|
|
26
|
+
const plugin = TiptapFloatingMenu.configure({
|
|
27
|
+
element: menuElement,
|
|
28
|
+
tippyOptions: {
|
|
29
|
+
duration: 150,
|
|
30
|
+
placement: 'left-start',
|
|
31
|
+
},
|
|
32
|
+
shouldShow: ({ editor: e, state }) => {
|
|
33
|
+
const { selection } = state;
|
|
34
|
+
const { $anchor: anchor, empty } = selection;
|
|
35
|
+
// Show only on empty paragraphs
|
|
36
|
+
if (!empty) return false;
|
|
37
|
+
const isRootDepth = anchor.depth === 1;
|
|
38
|
+
const isEmptyTextBlock = anchor.parent.isTextblock
|
|
39
|
+
&& !anchor.parent.type.spec.code
|
|
40
|
+
&& !anchor.parent.textContent;
|
|
41
|
+
return isRootDepth && isEmptyTextBlock;
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
editor.registerPlugin(plugin.options.plugin(plugin.options));
|
|
46
|
+
});
|
|
47
|
+
</script>
|
|
48
|
+
|
|
49
|
+
<div class="svadmin-floating-menu" bind:this={menuElement} style="visibility: hidden;">
|
|
50
|
+
<ToolbarButton title={t('editor.h1')} onclick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}>
|
|
51
|
+
<Heading1 />
|
|
52
|
+
</ToolbarButton>
|
|
53
|
+
<ToolbarButton title={t('editor.h2')} onclick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}>
|
|
54
|
+
<Heading2 />
|
|
55
|
+
</ToolbarButton>
|
|
56
|
+
<ToolbarButton title={t('editor.h3')} onclick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}>
|
|
57
|
+
<Heading3 />
|
|
58
|
+
</ToolbarButton>
|
|
59
|
+
<ToolbarButton title={t('editor.bulletList')} onclick={() => editor.chain().focus().toggleBulletList().run()}>
|
|
60
|
+
<List />
|
|
61
|
+
</ToolbarButton>
|
|
62
|
+
<ToolbarButton title={t('editor.orderedList')} onclick={() => editor.chain().focus().toggleOrderedList().run()}>
|
|
63
|
+
<ListOrdered />
|
|
64
|
+
</ToolbarButton>
|
|
65
|
+
<ToolbarButton title={t('editor.taskList')} onclick={() => editor.chain().focus().toggleTaskList().run()}>
|
|
66
|
+
<ListChecks />
|
|
67
|
+
</ToolbarButton>
|
|
68
|
+
<ToolbarButton title={t('editor.blockquote')} onclick={() => editor.chain().focus().toggleBlockquote().run()}>
|
|
69
|
+
<Quote />
|
|
70
|
+
</ToolbarButton>
|
|
71
|
+
<ToolbarButton title={t('editor.codeBlock')} onclick={() => editor.chain().focus().toggleCodeBlock().run()}>
|
|
72
|
+
<Code2 />
|
|
73
|
+
</ToolbarButton>
|
|
74
|
+
<ToolbarButton title="Divider" onclick={() => editor.chain().focus().setHorizontalRule().run()}>
|
|
75
|
+
<Minus />
|
|
76
|
+
</ToolbarButton>
|
|
77
|
+
</div>
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { Editor } from '@tiptap/core';
|
|
3
|
+
import { ImageIcon, Upload, Link as LinkIcon, X } from 'lucide-svelte';
|
|
4
|
+
import { t } from '@svadmin/core';
|
|
5
|
+
|
|
6
|
+
let { editor, onUpload, onclose } = $props<{
|
|
7
|
+
editor: Editor;
|
|
8
|
+
onUpload?: (file: File) => Promise<string>;
|
|
9
|
+
onclose?: () => void;
|
|
10
|
+
}>();
|
|
11
|
+
|
|
12
|
+
let mode: 'url' | 'upload' = $state('url');
|
|
13
|
+
let url = $state('');
|
|
14
|
+
let alt = $state('');
|
|
15
|
+
let uploading = $state(false);
|
|
16
|
+
let dragover = $state(false);
|
|
17
|
+
let fileInput: HTMLInputElement | undefined = $state();
|
|
18
|
+
|
|
19
|
+
function insertByUrl() {
|
|
20
|
+
if (!url.trim()) return;
|
|
21
|
+
editor.chain().focus().setImage({ src: url, alt: alt || undefined }).run();
|
|
22
|
+
url = '';
|
|
23
|
+
alt = '';
|
|
24
|
+
onclose?.();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function handleFiles(files: FileList | null) {
|
|
28
|
+
if (!files?.length || !onUpload) return;
|
|
29
|
+
uploading = true;
|
|
30
|
+
try {
|
|
31
|
+
for (const file of files) {
|
|
32
|
+
if (!file.type.startsWith('image/')) continue;
|
|
33
|
+
const uploadedUrl = await onUpload(file);
|
|
34
|
+
editor.chain().focus().setImage({ src: uploadedUrl, alt: file.name }).run();
|
|
35
|
+
}
|
|
36
|
+
} catch (e) {
|
|
37
|
+
console.error('Image upload failed:', e);
|
|
38
|
+
} finally {
|
|
39
|
+
uploading = false;
|
|
40
|
+
onclose?.();
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function handleDrop(e: DragEvent) {
|
|
45
|
+
e.preventDefault();
|
|
46
|
+
dragover = false;
|
|
47
|
+
handleFiles(e.dataTransfer?.files ?? null);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function handleDragOver(e: DragEvent) {
|
|
51
|
+
e.preventDefault();
|
|
52
|
+
dragover = true;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function handleDragLeave() {
|
|
56
|
+
dragover = false;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function handleKeydown(e: KeyboardEvent) {
|
|
60
|
+
if (e.key === 'Enter') {
|
|
61
|
+
e.preventDefault();
|
|
62
|
+
insertByUrl();
|
|
63
|
+
}
|
|
64
|
+
if (e.key === 'Escape') {
|
|
65
|
+
onclose?.();
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
</script>
|
|
69
|
+
|
|
70
|
+
<div class="svadmin-color-picker" style="min-width: 300px;">
|
|
71
|
+
<div style="display: flex; gap: 2px; margin-bottom: 8px;">
|
|
72
|
+
<button
|
|
73
|
+
type="button"
|
|
74
|
+
class="svadmin-toolbar-btn"
|
|
75
|
+
style="width: auto; padding: 0 10px; font-size: 12px; gap: 4px; display: flex; align-items: center;"
|
|
76
|
+
data-active={mode === 'url'}
|
|
77
|
+
onclick={() => { mode = 'url'; }}
|
|
78
|
+
>
|
|
79
|
+
<LinkIcon style="width: 12px; height: 12px;" />
|
|
80
|
+
URL
|
|
81
|
+
</button>
|
|
82
|
+
{#if onUpload}
|
|
83
|
+
<button
|
|
84
|
+
type="button"
|
|
85
|
+
class="svadmin-toolbar-btn"
|
|
86
|
+
style="width: auto; padding: 0 10px; font-size: 12px; gap: 4px; display: flex; align-items: center;"
|
|
87
|
+
data-active={mode === 'upload'}
|
|
88
|
+
onclick={() => { mode = 'upload'; }}
|
|
89
|
+
>
|
|
90
|
+
<Upload style="width: 12px; height: 12px;" />
|
|
91
|
+
Upload
|
|
92
|
+
</button>
|
|
93
|
+
{/if}
|
|
94
|
+
</div>
|
|
95
|
+
|
|
96
|
+
{#if mode === 'url'}
|
|
97
|
+
<div style="display: flex; flex-direction: column; gap: 6px;">
|
|
98
|
+
<input
|
|
99
|
+
class="svadmin-link-input"
|
|
100
|
+
type="url"
|
|
101
|
+
placeholder={t('editor.image.url')}
|
|
102
|
+
bind:value={url}
|
|
103
|
+
onkeydown={handleKeydown}
|
|
104
|
+
style="width: 100%;"
|
|
105
|
+
/>
|
|
106
|
+
<input
|
|
107
|
+
class="svadmin-link-input"
|
|
108
|
+
type="text"
|
|
109
|
+
placeholder="Alt (optional)"
|
|
110
|
+
bind:value={alt}
|
|
111
|
+
onkeydown={handleKeydown}
|
|
112
|
+
style="width: 100%;"
|
|
113
|
+
/>
|
|
114
|
+
<button
|
|
115
|
+
type="button"
|
|
116
|
+
class="svadmin-toolbar-btn"
|
|
117
|
+
style="width: 100%; font-size: 13px; height: 34px; background: hsl(var(--primary) / 0.1); color: var(--editor-primary); font-weight: 500;"
|
|
118
|
+
onclick={insertByUrl}
|
|
119
|
+
disabled={!url.trim()}
|
|
120
|
+
>
|
|
121
|
+
{t('editor.image.apply') || t('editor.link.apply')}
|
|
122
|
+
</button>
|
|
123
|
+
</div>
|
|
124
|
+
{:else}
|
|
125
|
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
126
|
+
<div
|
|
127
|
+
style="
|
|
128
|
+
border: 2px dashed {dragover ? 'var(--editor-primary)' : 'var(--editor-border)'};
|
|
129
|
+
border-radius: 8px;
|
|
130
|
+
padding: 24px;
|
|
131
|
+
text-align: center;
|
|
132
|
+
cursor: pointer;
|
|
133
|
+
transition: border-color 0.15s ease, background 0.15s ease;
|
|
134
|
+
background: {dragover ? 'hsl(var(--primary) / 0.05)' : 'transparent'};
|
|
135
|
+
"
|
|
136
|
+
ondrop={handleDrop}
|
|
137
|
+
ondragover={handleDragOver}
|
|
138
|
+
ondragleave={handleDragLeave}
|
|
139
|
+
onclick={() => fileInput?.click()}
|
|
140
|
+
>
|
|
141
|
+
{#if uploading}
|
|
142
|
+
<div style="color: var(--editor-muted); font-size: 13px;">{t('editor.image.uploading')}</div>
|
|
143
|
+
{:else}
|
|
144
|
+
<ImageIcon style="width: 24px; height: 24px; margin: 0 auto 8px; color: var(--editor-muted);" />
|
|
145
|
+
<div style="font-size: 13px; color: var(--editor-muted);">
|
|
146
|
+
{t('editor.image.upload')}
|
|
147
|
+
</div>
|
|
148
|
+
{/if}
|
|
149
|
+
</div>
|
|
150
|
+
<input
|
|
151
|
+
bind:this={fileInput}
|
|
152
|
+
type="file"
|
|
153
|
+
accept="image/*"
|
|
154
|
+
multiple
|
|
155
|
+
style="display: none;"
|
|
156
|
+
onchange={(e) => handleFiles((e.target as HTMLInputElement).files)}
|
|
157
|
+
/>
|
|
158
|
+
{/if}
|
|
159
|
+
</div>
|