@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
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { Editor } from '@tiptap/core';
|
|
3
|
+
import { ExternalLink, Unlink, Check } from 'lucide-svelte';
|
|
4
|
+
import { t } from '@svadmin/core';
|
|
5
|
+
|
|
6
|
+
let { editor, onclose } = $props<{
|
|
7
|
+
editor: Editor;
|
|
8
|
+
onclose?: () => void;
|
|
9
|
+
}>();
|
|
10
|
+
|
|
11
|
+
let url = $state('');
|
|
12
|
+
let openInNewTab = $state(true);
|
|
13
|
+
|
|
14
|
+
// Initialize from current link
|
|
15
|
+
$effect(() => {
|
|
16
|
+
const attrs = editor.getAttributes('link');
|
|
17
|
+
url = (attrs.href as string) ?? '';
|
|
18
|
+
openInNewTab = attrs.target === '_blank';
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
function applyLink() {
|
|
22
|
+
if (!url) {
|
|
23
|
+
editor.chain().focus().unsetLink().run();
|
|
24
|
+
} else {
|
|
25
|
+
editor.chain().focus().extendMarkRange('link').setLink({
|
|
26
|
+
href: url,
|
|
27
|
+
target: openInNewTab ? '_blank' : null,
|
|
28
|
+
}).run();
|
|
29
|
+
}
|
|
30
|
+
onclose?.();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function removeLink() {
|
|
34
|
+
editor.chain().focus().unsetLink().run();
|
|
35
|
+
onclose?.();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function handleKeydown(e: KeyboardEvent) {
|
|
39
|
+
if (e.key === 'Enter') {
|
|
40
|
+
e.preventDefault();
|
|
41
|
+
applyLink();
|
|
42
|
+
}
|
|
43
|
+
if (e.key === 'Escape') {
|
|
44
|
+
onclose?.();
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
</script>
|
|
48
|
+
|
|
49
|
+
<div class="svadmin-link-popover">
|
|
50
|
+
<input
|
|
51
|
+
class="svadmin-link-input"
|
|
52
|
+
type="url"
|
|
53
|
+
placeholder={t('editor.link.url')}
|
|
54
|
+
bind:value={url}
|
|
55
|
+
onkeydown={handleKeydown}
|
|
56
|
+
/>
|
|
57
|
+
|
|
58
|
+
<button
|
|
59
|
+
type="button"
|
|
60
|
+
class="svadmin-toolbar-btn"
|
|
61
|
+
title={t('editor.link.openInNewTab')}
|
|
62
|
+
data-active={openInNewTab}
|
|
63
|
+
onclick={() => { openInNewTab = !openInNewTab; }}
|
|
64
|
+
>
|
|
65
|
+
<ExternalLink />
|
|
66
|
+
</button>
|
|
67
|
+
|
|
68
|
+
<button
|
|
69
|
+
type="button"
|
|
70
|
+
class="svadmin-toolbar-btn"
|
|
71
|
+
title={t('editor.link.apply')}
|
|
72
|
+
onclick={applyLink}
|
|
73
|
+
>
|
|
74
|
+
<Check />
|
|
75
|
+
</button>
|
|
76
|
+
|
|
77
|
+
{#if editor.isActive('link')}
|
|
78
|
+
<button
|
|
79
|
+
type="button"
|
|
80
|
+
class="svadmin-toolbar-btn"
|
|
81
|
+
title={t('editor.link.remove')}
|
|
82
|
+
onclick={removeLink}
|
|
83
|
+
>
|
|
84
|
+
<Unlink />
|
|
85
|
+
</button>
|
|
86
|
+
{/if}
|
|
87
|
+
</div>
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { Editor } from '@tiptap/core';
|
|
3
|
+
import {
|
|
4
|
+
TableIcon, Plus, Minus, Trash2,
|
|
5
|
+
MergeIcon, SplitIcon,
|
|
6
|
+
ArrowDownToLine, ArrowRightToLine,
|
|
7
|
+
} from 'lucide-svelte';
|
|
8
|
+
import ToolbarButton from './ToolbarButton.svelte';
|
|
9
|
+
import { t } from '@svadmin/core';
|
|
10
|
+
|
|
11
|
+
let { editor, onclose } = $props<{
|
|
12
|
+
editor: Editor;
|
|
13
|
+
onclose?: () => void;
|
|
14
|
+
}>();
|
|
15
|
+
|
|
16
|
+
const isInTable = $derived(editor.isActive('table'));
|
|
17
|
+
|
|
18
|
+
function insertTable() {
|
|
19
|
+
editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run();
|
|
20
|
+
onclose?.();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function addRowBefore() { editor.chain().focus().addRowBefore().run(); }
|
|
24
|
+
function addRowAfter() { editor.chain().focus().addRowAfter().run(); }
|
|
25
|
+
function deleteRow() { editor.chain().focus().deleteRow().run(); }
|
|
26
|
+
function addColumnBefore() { editor.chain().focus().addColumnBefore().run(); }
|
|
27
|
+
function addColumnAfter() { editor.chain().focus().addColumnAfter().run(); }
|
|
28
|
+
function deleteColumn() { editor.chain().focus().deleteColumn().run(); }
|
|
29
|
+
function mergeCells() { editor.chain().focus().mergeCells().run(); }
|
|
30
|
+
function splitCell() { editor.chain().focus().splitCell().run(); }
|
|
31
|
+
function deleteTable() { editor.chain().focus().deleteTable().run(); onclose?.(); }
|
|
32
|
+
function toggleHeaderRow() { editor.chain().focus().toggleHeaderRow().run(); }
|
|
33
|
+
</script>
|
|
34
|
+
|
|
35
|
+
{#if !isInTable}
|
|
36
|
+
<ToolbarButton title={t('editor.table')} onclick={insertTable}>
|
|
37
|
+
<TableIcon />
|
|
38
|
+
</ToolbarButton>
|
|
39
|
+
{:else}
|
|
40
|
+
<div class="svadmin-color-picker" style="min-width: 180px;">
|
|
41
|
+
<div style="display: flex; flex-direction: column; gap: 2px;">
|
|
42
|
+
<div style="font-size: 11px; font-weight: 600; color: var(--editor-muted); padding: 2px 4px; text-transform: uppercase; letter-spacing: 0.05em;">
|
|
43
|
+
Rows
|
|
44
|
+
</div>
|
|
45
|
+
<div style="display: flex; gap: 2px;">
|
|
46
|
+
<ToolbarButton title={t('editor.table.insertRowAbove')} onclick={addRowBefore}>
|
|
47
|
+
<Plus />
|
|
48
|
+
</ToolbarButton>
|
|
49
|
+
<ToolbarButton title={t('editor.table.insertRowBelow')} onclick={addRowAfter}>
|
|
50
|
+
<ArrowDownToLine />
|
|
51
|
+
</ToolbarButton>
|
|
52
|
+
<ToolbarButton title={t('editor.table.deleteRow')} onclick={deleteRow}>
|
|
53
|
+
<Minus />
|
|
54
|
+
</ToolbarButton>
|
|
55
|
+
</div>
|
|
56
|
+
|
|
57
|
+
<div style="font-size: 11px; font-weight: 600; color: var(--editor-muted); padding: 2px 4px; text-transform: uppercase; letter-spacing: 0.05em; margin-top: 4px;">
|
|
58
|
+
Columns
|
|
59
|
+
</div>
|
|
60
|
+
<div style="display: flex; gap: 2px;">
|
|
61
|
+
<ToolbarButton title={t('editor.table.insertColumnLeft')} onclick={addColumnBefore}>
|
|
62
|
+
<Plus />
|
|
63
|
+
</ToolbarButton>
|
|
64
|
+
<ToolbarButton title={t('editor.table.insertColumnRight')} onclick={addColumnAfter}>
|
|
65
|
+
<ArrowRightToLine />
|
|
66
|
+
</ToolbarButton>
|
|
67
|
+
<ToolbarButton title={t('editor.table.deleteColumn')} onclick={deleteColumn}>
|
|
68
|
+
<Minus />
|
|
69
|
+
</ToolbarButton>
|
|
70
|
+
</div>
|
|
71
|
+
|
|
72
|
+
<div style="font-size: 11px; font-weight: 600; color: var(--editor-muted); padding: 2px 4px; text-transform: uppercase; letter-spacing: 0.05em; margin-top: 4px;">
|
|
73
|
+
Cells
|
|
74
|
+
</div>
|
|
75
|
+
<div style="display: flex; gap: 2px;">
|
|
76
|
+
<ToolbarButton title={t('editor.table.mergeCells')} onclick={mergeCells}>
|
|
77
|
+
<MergeIcon />
|
|
78
|
+
</ToolbarButton>
|
|
79
|
+
<ToolbarButton title="Split cell" onclick={splitCell}>
|
|
80
|
+
<SplitIcon />
|
|
81
|
+
</ToolbarButton>
|
|
82
|
+
<ToolbarButton title="Toggle header row" onclick={toggleHeaderRow}>
|
|
83
|
+
<TableIcon />
|
|
84
|
+
</ToolbarButton>
|
|
85
|
+
</div>
|
|
86
|
+
|
|
87
|
+
<div style="border-top: 1px solid var(--editor-border); margin-top: 4px; padding-top: 4px;">
|
|
88
|
+
<ToolbarButton title={t('editor.table.deleteTable')} onclick={deleteTable}>
|
|
89
|
+
<Trash2 />
|
|
90
|
+
</ToolbarButton>
|
|
91
|
+
</div>
|
|
92
|
+
</div>
|
|
93
|
+
</div>
|
|
94
|
+
{/if}
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { Editor } from '@tiptap/core';
|
|
3
|
+
import ToolbarButton from './ToolbarButton.svelte';
|
|
4
|
+
import ToolbarGroup from './ToolbarGroup.svelte';
|
|
5
|
+
import ToolbarSelect from './ToolbarSelect.svelte';
|
|
6
|
+
import ColorPicker from './ColorPicker.svelte';
|
|
7
|
+
import LinkPopover from './LinkPopover.svelte';
|
|
8
|
+
import TableMenu from './TableMenu.svelte';
|
|
9
|
+
import ImageUpload from './ImageUpload.svelte';
|
|
10
|
+
import {
|
|
11
|
+
Bold, Italic, Underline as UnderlineIcon, Strikethrough,
|
|
12
|
+
Heading1, Heading2, Heading3,
|
|
13
|
+
List, ListOrdered, ListChecks,
|
|
14
|
+
AlignLeft, AlignCenter, AlignRight, AlignJustify,
|
|
15
|
+
Quote, Minus, Code, Code2,
|
|
16
|
+
Undo2, Redo2,
|
|
17
|
+
Link as LinkIcon, ImageIcon, TableIcon,
|
|
18
|
+
Palette, Highlighter, Type,
|
|
19
|
+
Subscript, Superscript,
|
|
20
|
+
Pilcrow,
|
|
21
|
+
} from 'lucide-svelte';
|
|
22
|
+
import { t } from '@svadmin/core';
|
|
23
|
+
|
|
24
|
+
let { editor, onUpload, preset = 'full' } = $props<{
|
|
25
|
+
editor: Editor;
|
|
26
|
+
onUpload?: (file: File) => Promise<string>;
|
|
27
|
+
preset?: 'full' | 'minimal' | 'prose';
|
|
28
|
+
}>();
|
|
29
|
+
|
|
30
|
+
// ─── Reactive state: re-read on every transaction ────────────
|
|
31
|
+
let txn = $state(0);
|
|
32
|
+
|
|
33
|
+
$effect(() => {
|
|
34
|
+
const handler = () => { txn++; };
|
|
35
|
+
editor.on('transaction', handler);
|
|
36
|
+
return () => { editor.off('transaction', handler); };
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
// We use txn to make derived values reactive.
|
|
40
|
+
// The `void txn` below ensures Svelte tracks the dependency.
|
|
41
|
+
const isBold = $derived((void txn, editor.isActive('bold')));
|
|
42
|
+
const isItalic = $derived((void txn, editor.isActive('italic')));
|
|
43
|
+
const isUnderline = $derived((void txn, editor.isActive('underline')));
|
|
44
|
+
const isStrike = $derived((void txn, editor.isActive('strike')));
|
|
45
|
+
const isSubscript = $derived((void txn, editor.isActive('subscript')));
|
|
46
|
+
const isSuperscript = $derived((void txn, editor.isActive('superscript')));
|
|
47
|
+
const isBulletList = $derived((void txn, editor.isActive('bulletList')));
|
|
48
|
+
const isOrderedList = $derived((void txn, editor.isActive('orderedList')));
|
|
49
|
+
const isTaskList = $derived((void txn, editor.isActive('taskList')));
|
|
50
|
+
const isBlockquote = $derived((void txn, editor.isActive('blockquote')));
|
|
51
|
+
const isCode = $derived((void txn, editor.isActive('code')));
|
|
52
|
+
const isCodeBlock = $derived((void txn, editor.isActive('codeBlock')));
|
|
53
|
+
const isLink = $derived((void txn, editor.isActive('link')));
|
|
54
|
+
const isTable = $derived((void txn, editor.isActive('table')));
|
|
55
|
+
const isAlignLeft = $derived((void txn, editor.isActive({ textAlign: 'left' })));
|
|
56
|
+
const isAlignCenter = $derived((void txn, editor.isActive({ textAlign: 'center' })));
|
|
57
|
+
const isAlignRight = $derived((void txn, editor.isActive({ textAlign: 'right' })));
|
|
58
|
+
const isAlignJustify = $derived((void txn, editor.isActive({ textAlign: 'justify' })));
|
|
59
|
+
|
|
60
|
+
const currentHeading = $derived((void txn, (() => {
|
|
61
|
+
for (let i = 1; i <= 4; i++) {
|
|
62
|
+
if (editor.isActive('heading', { level: i })) return String(i);
|
|
63
|
+
}
|
|
64
|
+
return '0';
|
|
65
|
+
})()));
|
|
66
|
+
|
|
67
|
+
const canUndo = $derived((void txn, editor.can().undo()));
|
|
68
|
+
const canRedo = $derived((void txn, editor.can().redo()));
|
|
69
|
+
|
|
70
|
+
// ─── Popover states ──────────────────────────────────────────
|
|
71
|
+
let showTextColor = $state(false);
|
|
72
|
+
let showHighlight = $state(false);
|
|
73
|
+
let showLink = $state(false);
|
|
74
|
+
let showTable = $state(false);
|
|
75
|
+
let showImage = $state(false);
|
|
76
|
+
|
|
77
|
+
function closeAll() {
|
|
78
|
+
showTextColor = false;
|
|
79
|
+
showHighlight = false;
|
|
80
|
+
showLink = false;
|
|
81
|
+
showTable = false;
|
|
82
|
+
showImage = false;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function togglePopover(name: 'textColor' | 'highlight' | 'link' | 'table' | 'image') {
|
|
86
|
+
const prev = {
|
|
87
|
+
textColor: showTextColor,
|
|
88
|
+
highlight: showHighlight,
|
|
89
|
+
link: showLink,
|
|
90
|
+
table: showTable,
|
|
91
|
+
image: showImage,
|
|
92
|
+
}[name];
|
|
93
|
+
closeAll();
|
|
94
|
+
if (!prev) {
|
|
95
|
+
switch (name) {
|
|
96
|
+
case 'textColor': showTextColor = true; break;
|
|
97
|
+
case 'highlight': showHighlight = true; break;
|
|
98
|
+
case 'link': showLink = true; break;
|
|
99
|
+
case 'table': showTable = true; break;
|
|
100
|
+
case 'image': showImage = true; break;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ─── Heading selection ───────────────────────────────────────
|
|
106
|
+
const headingOptions = $derived([
|
|
107
|
+
{ value: '0', label: 'Paragraph' },
|
|
108
|
+
{ value: '1', label: t('editor.h1') },
|
|
109
|
+
{ value: '2', label: t('editor.h2') },
|
|
110
|
+
{ value: '3', label: t('editor.h3') },
|
|
111
|
+
{ value: '4', label: t('editor.h4') },
|
|
112
|
+
]);
|
|
113
|
+
|
|
114
|
+
function setHeading(val: string) {
|
|
115
|
+
const level = parseInt(val);
|
|
116
|
+
if (level === 0) {
|
|
117
|
+
editor.chain().focus().setParagraph().run();
|
|
118
|
+
} else {
|
|
119
|
+
editor.chain().focus().toggleHeading({ level: level as 1 | 2 | 3 | 4 }).run();
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const isFullOrProse = preset === 'full' || preset === 'prose';
|
|
124
|
+
</script>
|
|
125
|
+
|
|
126
|
+
<div class="svadmin-editor-toolbar">
|
|
127
|
+
<!-- History -->
|
|
128
|
+
<ToolbarGroup>
|
|
129
|
+
<ToolbarButton title={t('editor.undo')} disabled={!canUndo} onclick={() => editor.chain().focus().undo().run()}>
|
|
130
|
+
<Undo2 />
|
|
131
|
+
</ToolbarButton>
|
|
132
|
+
<ToolbarButton title={t('editor.redo')} disabled={!canRedo} onclick={() => editor.chain().focus().redo().run()}>
|
|
133
|
+
<Redo2 />
|
|
134
|
+
</ToolbarButton>
|
|
135
|
+
</ToolbarGroup>
|
|
136
|
+
|
|
137
|
+
<!-- Block type -->
|
|
138
|
+
<ToolbarGroup>
|
|
139
|
+
<ToolbarSelect
|
|
140
|
+
title="Block type"
|
|
141
|
+
value={currentHeading}
|
|
142
|
+
options={headingOptions}
|
|
143
|
+
onchange={setHeading}
|
|
144
|
+
/>
|
|
145
|
+
</ToolbarGroup>
|
|
146
|
+
|
|
147
|
+
<!-- Text formatting -->
|
|
148
|
+
<ToolbarGroup>
|
|
149
|
+
<ToolbarButton title={t('editor.bold')} active={isBold} onclick={() => editor.chain().focus().toggleBold().run()}>
|
|
150
|
+
<Bold />
|
|
151
|
+
</ToolbarButton>
|
|
152
|
+
<ToolbarButton title={t('editor.italic')} active={isItalic} onclick={() => editor.chain().focus().toggleItalic().run()}>
|
|
153
|
+
<Italic />
|
|
154
|
+
</ToolbarButton>
|
|
155
|
+
<ToolbarButton title={t('editor.underline')} active={isUnderline} onclick={() => editor.chain().focus().toggleUnderline().run()}>
|
|
156
|
+
<UnderlineIcon />
|
|
157
|
+
</ToolbarButton>
|
|
158
|
+
<ToolbarButton title={t('editor.strikethrough')} active={isStrike} onclick={() => editor.chain().focus().toggleStrike().run()}>
|
|
159
|
+
<Strikethrough />
|
|
160
|
+
</ToolbarButton>
|
|
161
|
+
{#if isFullOrProse}
|
|
162
|
+
<ToolbarButton title="Subscript" active={isSubscript} onclick={() => editor.chain().focus().toggleSubscript().run()}>
|
|
163
|
+
<Subscript />
|
|
164
|
+
</ToolbarButton>
|
|
165
|
+
<ToolbarButton title="Superscript" active={isSuperscript} onclick={() => editor.chain().focus().toggleSuperscript().run()}>
|
|
166
|
+
<Superscript />
|
|
167
|
+
</ToolbarButton>
|
|
168
|
+
{/if}
|
|
169
|
+
</ToolbarGroup>
|
|
170
|
+
|
|
171
|
+
<!-- Colors -->
|
|
172
|
+
{#if isFullOrProse}
|
|
173
|
+
<ToolbarGroup>
|
|
174
|
+
<div style="position: relative;">
|
|
175
|
+
<ToolbarButton title={t('editor.color')} onclick={() => togglePopover('textColor')}>
|
|
176
|
+
<Palette />
|
|
177
|
+
</ToolbarButton>
|
|
178
|
+
{#if showTextColor}
|
|
179
|
+
<div style="position: absolute; top: 36px; left: 0; z-index: 50;">
|
|
180
|
+
<ColorPicker
|
|
181
|
+
onselect={(color) => {
|
|
182
|
+
if (color) {
|
|
183
|
+
editor.chain().focus().setColor(color).run();
|
|
184
|
+
} else {
|
|
185
|
+
editor.chain().focus().unsetColor().run();
|
|
186
|
+
}
|
|
187
|
+
showTextColor = false;
|
|
188
|
+
}}
|
|
189
|
+
onclose={() => { showTextColor = false; }}
|
|
190
|
+
/>
|
|
191
|
+
</div>
|
|
192
|
+
{/if}
|
|
193
|
+
</div>
|
|
194
|
+
<div style="position: relative;">
|
|
195
|
+
<ToolbarButton title={t('editor.highlight')} onclick={() => togglePopover('highlight')}>
|
|
196
|
+
<Highlighter />
|
|
197
|
+
</ToolbarButton>
|
|
198
|
+
{#if showHighlight}
|
|
199
|
+
<div style="position: absolute; top: 36px; left: 0; z-index: 50;">
|
|
200
|
+
<ColorPicker
|
|
201
|
+
onselect={(color) => {
|
|
202
|
+
if (color) {
|
|
203
|
+
editor.chain().focus().toggleHighlight({ color }).run();
|
|
204
|
+
} else {
|
|
205
|
+
editor.chain().focus().unsetHighlight().run();
|
|
206
|
+
}
|
|
207
|
+
showHighlight = false;
|
|
208
|
+
}}
|
|
209
|
+
onclose={() => { showHighlight = false; }}
|
|
210
|
+
/>
|
|
211
|
+
</div>
|
|
212
|
+
{/if}
|
|
213
|
+
</div>
|
|
214
|
+
</ToolbarGroup>
|
|
215
|
+
{/if}
|
|
216
|
+
|
|
217
|
+
<!-- Lists -->
|
|
218
|
+
<ToolbarGroup>
|
|
219
|
+
<ToolbarButton title={t('editor.bulletList')} active={isBulletList} onclick={() => editor.chain().focus().toggleBulletList().run()}>
|
|
220
|
+
<List />
|
|
221
|
+
</ToolbarButton>
|
|
222
|
+
<ToolbarButton title={t('editor.orderedList')} active={isOrderedList} onclick={() => editor.chain().focus().toggleOrderedList().run()}>
|
|
223
|
+
<ListOrdered />
|
|
224
|
+
</ToolbarButton>
|
|
225
|
+
{#if isFullOrProse}
|
|
226
|
+
<ToolbarButton title={t('editor.taskList')} active={isTaskList} onclick={() => editor.chain().focus().toggleTaskList().run()}>
|
|
227
|
+
<ListChecks />
|
|
228
|
+
</ToolbarButton>
|
|
229
|
+
{/if}
|
|
230
|
+
</ToolbarGroup>
|
|
231
|
+
|
|
232
|
+
<!-- Alignment -->
|
|
233
|
+
{#if isFullOrProse}
|
|
234
|
+
<ToolbarGroup>
|
|
235
|
+
<ToolbarButton title={t('editor.alignLeft')} active={isAlignLeft} onclick={() => editor.chain().focus().setTextAlign('left').run()}>
|
|
236
|
+
<AlignLeft />
|
|
237
|
+
</ToolbarButton>
|
|
238
|
+
<ToolbarButton title={t('editor.alignCenter')} active={isAlignCenter} onclick={() => editor.chain().focus().setTextAlign('center').run()}>
|
|
239
|
+
<AlignCenter />
|
|
240
|
+
</ToolbarButton>
|
|
241
|
+
<ToolbarButton title={t('editor.alignRight')} active={isAlignRight} onclick={() => editor.chain().focus().setTextAlign('right').run()}>
|
|
242
|
+
<AlignRight />
|
|
243
|
+
</ToolbarButton>
|
|
244
|
+
<ToolbarButton title={t('editor.alignJustify')} active={isAlignJustify} onclick={() => editor.chain().focus().setTextAlign('justify').run()}>
|
|
245
|
+
<AlignJustify />
|
|
246
|
+
</ToolbarButton>
|
|
247
|
+
</ToolbarGroup>
|
|
248
|
+
{/if}
|
|
249
|
+
|
|
250
|
+
<!-- Block elements -->
|
|
251
|
+
<ToolbarGroup>
|
|
252
|
+
<ToolbarButton title={t('editor.blockquote')} active={isBlockquote} onclick={() => editor.chain().focus().toggleBlockquote().run()}>
|
|
253
|
+
<Quote />
|
|
254
|
+
</ToolbarButton>
|
|
255
|
+
<ToolbarButton title={t('editor.code')} active={isCode} onclick={() => editor.chain().focus().toggleCode().run()}>
|
|
256
|
+
<Code />
|
|
257
|
+
</ToolbarButton>
|
|
258
|
+
<ToolbarButton title={t('editor.codeBlock')} active={isCodeBlock} onclick={() => editor.chain().focus().toggleCodeBlock().run()}>
|
|
259
|
+
<Code2 />
|
|
260
|
+
</ToolbarButton>
|
|
261
|
+
<ToolbarButton title="Horizontal rule" onclick={() => editor.chain().focus().setHorizontalRule().run()}>
|
|
262
|
+
<Minus />
|
|
263
|
+
</ToolbarButton>
|
|
264
|
+
</ToolbarGroup>
|
|
265
|
+
|
|
266
|
+
<!-- Insert: Link, Image, Table -->
|
|
267
|
+
<ToolbarGroup>
|
|
268
|
+
<div style="position: relative;">
|
|
269
|
+
<ToolbarButton title={t('editor.link')} active={isLink} onclick={() => togglePopover('link')}>
|
|
270
|
+
<LinkIcon />
|
|
271
|
+
</ToolbarButton>
|
|
272
|
+
{#if showLink}
|
|
273
|
+
<div style="position: absolute; top: 36px; left: 0; z-index: 50;">
|
|
274
|
+
<LinkPopover {editor} onclose={() => { showLink = false; }} />
|
|
275
|
+
</div>
|
|
276
|
+
{/if}
|
|
277
|
+
</div>
|
|
278
|
+
|
|
279
|
+
<div style="position: relative;">
|
|
280
|
+
<ToolbarButton title={t('editor.image')} onclick={() => togglePopover('image')}>
|
|
281
|
+
<ImageIcon />
|
|
282
|
+
</ToolbarButton>
|
|
283
|
+
{#if showImage}
|
|
284
|
+
<div style="position: absolute; top: 36px; right: 0; z-index: 50;">
|
|
285
|
+
<ImageUpload {editor} {onUpload} onclose={() => { showImage = false; }} />
|
|
286
|
+
</div>
|
|
287
|
+
{/if}
|
|
288
|
+
</div>
|
|
289
|
+
|
|
290
|
+
{#if preset === 'full'}
|
|
291
|
+
<div style="position: relative;">
|
|
292
|
+
{#if isTable}
|
|
293
|
+
<ToolbarButton title={t('editor.table')} active={true} onclick={() => togglePopover('table')}>
|
|
294
|
+
<TableIcon />
|
|
295
|
+
</ToolbarButton>
|
|
296
|
+
{#if showTable}
|
|
297
|
+
<div style="position: absolute; top: 36px; right: 0; z-index: 50;">
|
|
298
|
+
<TableMenu {editor} onclose={() => { showTable = false; }} />
|
|
299
|
+
</div>
|
|
300
|
+
{/if}
|
|
301
|
+
{:else}
|
|
302
|
+
<ToolbarButton
|
|
303
|
+
title={t('editor.table')}
|
|
304
|
+
onclick={() => editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run()}
|
|
305
|
+
>
|
|
306
|
+
<TableIcon />
|
|
307
|
+
</ToolbarButton>
|
|
308
|
+
{/if}
|
|
309
|
+
</div>
|
|
310
|
+
{/if}
|
|
311
|
+
</ToolbarGroup>
|
|
312
|
+
</div>
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { Snippet } from 'svelte';
|
|
3
|
+
|
|
4
|
+
let { active = false, disabled = false, title = '', onclick, children } = $props<{
|
|
5
|
+
active?: boolean;
|
|
6
|
+
disabled?: boolean;
|
|
7
|
+
title?: string;
|
|
8
|
+
onclick?: () => void;
|
|
9
|
+
children: Snippet;
|
|
10
|
+
}>();
|
|
11
|
+
</script>
|
|
12
|
+
|
|
13
|
+
<button
|
|
14
|
+
type="button"
|
|
15
|
+
class="svadmin-toolbar-btn"
|
|
16
|
+
data-active={active}
|
|
17
|
+
{disabled}
|
|
18
|
+
{title}
|
|
19
|
+
aria-pressed={active}
|
|
20
|
+
aria-label={title}
|
|
21
|
+
onclick={(e) => {
|
|
22
|
+
e.preventDefault();
|
|
23
|
+
onclick?.();
|
|
24
|
+
}}
|
|
25
|
+
>
|
|
26
|
+
{@render children()}
|
|
27
|
+
</button>
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
let { value = '', options = [], title = '', onchange } = $props<{
|
|
3
|
+
value?: string;
|
|
4
|
+
options: Array<{ value: string; label: string }>;
|
|
5
|
+
title?: string;
|
|
6
|
+
onchange?: (value: string) => void;
|
|
7
|
+
}>();
|
|
8
|
+
</script>
|
|
9
|
+
|
|
10
|
+
<select
|
|
11
|
+
class="svadmin-toolbar-select"
|
|
12
|
+
{value}
|
|
13
|
+
{title}
|
|
14
|
+
aria-label={title}
|
|
15
|
+
onchange={(e) => {
|
|
16
|
+
const target = e.target as HTMLSelectElement;
|
|
17
|
+
onchange?.(target.value);
|
|
18
|
+
}}
|
|
19
|
+
>
|
|
20
|
+
{#each options as opt}
|
|
21
|
+
<option value={opt.value} selected={opt.value === value}>{opt.label}</option>
|
|
22
|
+
{/each}
|
|
23
|
+
</select>
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @svadmin/editor — Editor factory
|
|
3
|
+
*
|
|
4
|
+
* Creates and manages a Tiptap editor instance with Svelte 5 reactivity.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { Editor, type EditorOptions } from '@tiptap/core';
|
|
8
|
+
import { fullPreset, minimalPreset, prosePreset, type EditorPresetOptions } from './extensions/index.js';
|
|
9
|
+
import type { AnyExtension } from '@tiptap/core';
|
|
10
|
+
|
|
11
|
+
export interface SvadminEditorOptions {
|
|
12
|
+
/** Initial HTML content */
|
|
13
|
+
content?: string;
|
|
14
|
+
/** Placeholder text shown when editor is empty */
|
|
15
|
+
placeholder?: string;
|
|
16
|
+
/** Whether the editor is editable */
|
|
17
|
+
editable?: boolean;
|
|
18
|
+
/** Autofocus on mount */
|
|
19
|
+
autofocus?: boolean | 'start' | 'end';
|
|
20
|
+
/** Maximum character count (0 = unlimited) */
|
|
21
|
+
maxLength?: number;
|
|
22
|
+
/** Extension preset: 'full' | 'minimal' | 'prose' */
|
|
23
|
+
preset?: 'full' | 'minimal' | 'prose';
|
|
24
|
+
/** Custom extensions (overrides preset) */
|
|
25
|
+
extensions?: AnyExtension[];
|
|
26
|
+
/** Preset options */
|
|
27
|
+
presetOptions?: EditorPresetOptions;
|
|
28
|
+
/** Image upload handler */
|
|
29
|
+
onUpload?: (file: File) => Promise<string>;
|
|
30
|
+
/** Called on every content update */
|
|
31
|
+
onUpdate?: (html: string) => void;
|
|
32
|
+
/** Additional Tiptap EditorOptions overrides */
|
|
33
|
+
editorProps?: Partial<EditorOptions>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Create a Tiptap editor instance with svadmin defaults.
|
|
38
|
+
*/
|
|
39
|
+
export function createSvadminEditor(
|
|
40
|
+
element: HTMLElement,
|
|
41
|
+
options: SvadminEditorOptions = {}
|
|
42
|
+
): Editor {
|
|
43
|
+
const {
|
|
44
|
+
content = '',
|
|
45
|
+
placeholder = '',
|
|
46
|
+
editable = true,
|
|
47
|
+
autofocus = false,
|
|
48
|
+
maxLength = 0,
|
|
49
|
+
preset = 'full',
|
|
50
|
+
extensions: customExtensions,
|
|
51
|
+
presetOptions,
|
|
52
|
+
onUpdate,
|
|
53
|
+
editorProps,
|
|
54
|
+
} = options;
|
|
55
|
+
|
|
56
|
+
// Resolve extensions
|
|
57
|
+
let extensions: AnyExtension[];
|
|
58
|
+
if (customExtensions) {
|
|
59
|
+
extensions = customExtensions;
|
|
60
|
+
} else {
|
|
61
|
+
const opts: EditorPresetOptions = {
|
|
62
|
+
placeholder,
|
|
63
|
+
maxLength,
|
|
64
|
+
...presetOptions,
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
switch (preset) {
|
|
68
|
+
case 'minimal':
|
|
69
|
+
extensions = minimalPreset(opts);
|
|
70
|
+
break;
|
|
71
|
+
case 'prose':
|
|
72
|
+
extensions = prosePreset(opts);
|
|
73
|
+
break;
|
|
74
|
+
case 'full':
|
|
75
|
+
default:
|
|
76
|
+
extensions = fullPreset(opts);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const editor = new Editor({
|
|
81
|
+
element,
|
|
82
|
+
extensions,
|
|
83
|
+
content,
|
|
84
|
+
editable,
|
|
85
|
+
autofocus,
|
|
86
|
+
editorProps: {
|
|
87
|
+
attributes: {
|
|
88
|
+
class: 'tiptap',
|
|
89
|
+
},
|
|
90
|
+
...editorProps?.editorProps,
|
|
91
|
+
},
|
|
92
|
+
onUpdate({ editor: e }) {
|
|
93
|
+
onUpdate?.(e.getHTML());
|
|
94
|
+
},
|
|
95
|
+
...editorProps,
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
return editor;
|
|
99
|
+
}
|