@rimori/react-client 0.4.12-next.0 → 0.4.12-next.2
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.
|
@@ -32,6 +32,10 @@ export interface EditorLabels {
|
|
|
32
32
|
appendMarkdownTitle?: string;
|
|
33
33
|
appendMarkdownPlaceholder?: string;
|
|
34
34
|
appendMarkdownConfirm?: string;
|
|
35
|
+
transformSelection?: string;
|
|
36
|
+
transformSelectionTitle?: string;
|
|
37
|
+
transformSelectionPlaceholder?: string;
|
|
38
|
+
transformSelectionConfirm?: string;
|
|
35
39
|
cancel?: string;
|
|
36
40
|
}
|
|
37
41
|
export interface MarkdownEditorProps {
|
|
@@ -9,6 +9,7 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
|
|
|
9
9
|
};
|
|
10
10
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
11
11
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
12
|
+
import { LuWand } from 'react-icons/lu';
|
|
12
13
|
import { useRimori } from '../../providers/PluginProvider';
|
|
13
14
|
import { Markdown } from 'tiptap-markdown';
|
|
14
15
|
import StarterKit from '@tiptap/starter-kit';
|
|
@@ -24,7 +25,7 @@ import { PiCodeBlock } from 'react-icons/pi';
|
|
|
24
25
|
import { TbBlockquote, TbTable, TbColumnInsertRight, TbRowInsertBottom, TbColumnRemove, TbRowRemove, TbArrowMergeBoth, TbBrandYoutube, TbPhoto, } from 'react-icons/tb';
|
|
25
26
|
import { GoListOrdered } from 'react-icons/go';
|
|
26
27
|
import { AiOutlineUnorderedList } from 'react-icons/ai';
|
|
27
|
-
import { LuClipboardPaste, LuHeading1, LuHeading2, LuHeading3, LuLink,
|
|
28
|
+
import { LuClipboardPaste, LuHeading1, LuHeading2, LuHeading3, LuLink, LuCopy, LuCheck, LuMaximize2, LuMinimize2, LuLoader, } from 'react-icons/lu';
|
|
28
29
|
import { FaBold, FaCode, FaItalic, FaParagraph, FaStrikethrough } from 'react-icons/fa';
|
|
29
30
|
import { ImageUploadExtension, triggerImageUpload } from './ImageUploadExtension';
|
|
30
31
|
// Extends TipTap's Paragraph to serialize empty paragraphs as <p></p>.
|
|
@@ -71,7 +72,7 @@ const EditorButton = ({ editor, action, isActive, label, disabled, title }) => {
|
|
|
71
72
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
72
73
|
disabled: disabled ? !editor.can().chain()[action]().run() : false, className: baseClass, children: label }));
|
|
73
74
|
};
|
|
74
|
-
const InlinePanel = ({ panel, onClose, editor, onUpdate, labels }) => {
|
|
75
|
+
const InlinePanel = ({ panel, onClose, editor, onUpdate, labels, onTransform, isTransforming, }) => {
|
|
75
76
|
const [value, setValue] = useState('');
|
|
76
77
|
// Reset value when panel changes
|
|
77
78
|
useEffect(() => {
|
|
@@ -108,6 +109,11 @@ const InlinePanel = ({ panel, onClose, editor, onUpdate, labels }) => {
|
|
|
108
109
|
onUpdate(combined);
|
|
109
110
|
onClose();
|
|
110
111
|
}
|
|
112
|
+
else if (panel === 'transform') {
|
|
113
|
+
if (!value.trim() || !onTransform)
|
|
114
|
+
return;
|
|
115
|
+
onTransform(value.trim());
|
|
116
|
+
}
|
|
111
117
|
};
|
|
112
118
|
const handleKeyDown = (e) => {
|
|
113
119
|
if (e.key === 'Enter')
|
|
@@ -116,26 +122,42 @@ const InlinePanel = ({ panel, onClose, editor, onUpdate, labels }) => {
|
|
|
116
122
|
onClose();
|
|
117
123
|
};
|
|
118
124
|
const isMarkdown = panel === 'markdown';
|
|
125
|
+
const isTransform = panel === 'transform';
|
|
119
126
|
return (_jsxs("div", { className: "bg-muted/80 border-b border-border px-2 py-1.5 flex flex-col gap-1.5", children: [_jsx("p", { className: "text-xs text-muted-foreground", children: panel === 'link'
|
|
120
127
|
? labels.setLinkTitle
|
|
121
128
|
: panel === 'youtube'
|
|
122
129
|
? labels.addYoutubeTitle
|
|
123
|
-
:
|
|
124
|
-
? labels.
|
|
125
|
-
: panel === '
|
|
126
|
-
? labels.
|
|
127
|
-
:
|
|
130
|
+
: panel === 'transform'
|
|
131
|
+
? labels.transformSelectionTitle
|
|
132
|
+
: labels.appendMarkdownTitle }), isMarkdown || isTransform ? (_jsx("textarea", { autoFocus: true, rows: isTransform ? 2 : 4, value: value, onChange: (e) => setValue(e.target.value), onKeyDown: (e) => e.key === 'Escape' && onClose(), placeholder: isTransform ? labels.transformSelectionPlaceholder : labels.appendMarkdownPlaceholder, className: "w-full text-sm font-mono rounded border border-border bg-background px-2 py-1 resize-y outline-none focus:ring-1 focus:ring-ring" })) : (_jsx("input", { autoFocus: true, type: "url", value: value, onChange: (e) => setValue(e.target.value), onKeyDown: handleKeyDown, placeholder: panel === 'link' ? labels.setLinkUrlPlaceholder : labels.addYoutubeUrlPlaceholder, className: "w-full text-sm font-mono rounded border border-border bg-background px-2 py-1 outline-none focus:ring-1 focus:ring-ring" })), _jsxs("div", { className: "flex gap-2 justify-end", children: [_jsx("button", { type: "button", onClick: onClose, disabled: isTransforming, className: "text-xs px-3 py-1 rounded border border-border hover:bg-accent transition-colors disabled:opacity-40", children: labels.cancel }), _jsxs("button", { type: "button", onClick: handleConfirm, disabled: !value.trim() || (panel === 'link' && value.trim() === 'https://') || isTransforming, className: "text-xs px-3 py-1 rounded bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-40 transition-colors flex items-center gap-1", children: [isTransforming && _jsx(LuLoader, { size: 12, className: "animate-spin" }), panel === 'link'
|
|
133
|
+
? labels.setLinkConfirm
|
|
134
|
+
: panel === 'youtube'
|
|
135
|
+
? labels.addYoutubeConfirm
|
|
136
|
+
: panel === 'transform'
|
|
137
|
+
? labels.transformSelectionConfirm
|
|
138
|
+
: labels.appendMarkdownConfirm] })] })] }));
|
|
128
139
|
};
|
|
129
|
-
const MenuBar = ({ editor, onUpdate, uploadImage, labels, onCopy, copied, isFullscreen, onToggleFullscreen, }) => {
|
|
140
|
+
const MenuBar = ({ editor, onUpdate, uploadImage, labels, onCopy, copied, isFullscreen, onToggleFullscreen, hasSelection, onTransform, isTransforming, }) => {
|
|
130
141
|
const [activePanel, setActivePanel] = useState(null);
|
|
131
142
|
const toggle = (panel) => setActivePanel((prev) => (prev === panel ? null : panel));
|
|
132
143
|
const inTable = editor.isActive('table');
|
|
133
144
|
const isLink = editor.isActive('link');
|
|
134
145
|
const tableBtnClass = 'w-8 h-8 flex items-center justify-center rounded-md transition-colors duration-150 ' +
|
|
135
146
|
'text-muted-foreground hover:bg-accent hover:text-accent-foreground disabled:opacity-40 disabled:pointer-events-none';
|
|
136
|
-
return (_jsxs(_Fragment, { children: [_jsxs("div", { className: "bg-muted/50 border-b border-border text-base flex flex-row flex-wrap items-center gap-0.5 p-1.5", children: [_jsx(EditorButton, { editor: editor, action: "toggleBold", isActive: editor.isActive('bold'), label: _jsx(FaBold, {}), disabled: true, title: labels.bold }), _jsx(EditorButton, { editor: editor, action: "toggleItalic", isActive: editor.isActive('italic'), label: _jsx(FaItalic, {}), disabled: true, title: labels.italic }), _jsx(EditorButton, { editor: editor, action: "toggleStrike", isActive: editor.isActive('strike'), label: _jsx(FaStrikethrough, {}), disabled: true, title: labels.strike }), _jsx(EditorButton, { editor: editor, action: "toggleCode", isActive: editor.isActive('code'), label: _jsx(FaCode, {}), disabled: true, title: labels.code }), _jsx(EditorButton, { editor: editor, action: "setParagraph", isActive: editor.isActive('paragraph'), label: _jsx(FaParagraph, {}), title: labels.paragraph }), _jsx(EditorButton, { editor: editor, action: "setHeading1", isActive: editor.isActive('heading', { level: 1 }), label: _jsx(LuHeading1, { size: "24px" }), title: labels.heading1 }), _jsx(EditorButton, { editor: editor, action: "setHeading2", isActive: editor.isActive('heading', { level: 2 }), label: _jsx(LuHeading2, { size: "24px" }), title: labels.heading2 }), _jsx(EditorButton, { editor: editor, action: "setHeading3", isActive: editor.isActive('heading', { level: 3 }), label: _jsx(LuHeading3, { size: "24px" }), title: labels.heading3 }), _jsx(EditorButton, { editor: editor, action: "toggleBulletList", isActive: editor.isActive('bulletList'), label: _jsx(AiOutlineUnorderedList, { size: "24px" }), title: labels.bulletList }), _jsx(EditorButton, { editor: editor, action: "toggleOrderedList", isActive: editor.isActive('orderedList'), label: _jsx(GoListOrdered, { size: "24px" }), title: labels.orderedList }), _jsx(EditorButton, { editor: editor, action: "toggleCodeBlock", isActive: editor.isActive('codeBlock'), label: _jsx(PiCodeBlock, { size: "24px" }), title: labels.codeBlock }), _jsx(EditorButton, { editor: editor, action: "toggleBlockquote", isActive: editor.isActive('blockquote'), label: _jsx(TbBlockquote, { size: "24px" }), title: labels.blockquote }), _jsx("div", { className: "w-px h-5 bg-border mx-0.5" }),
|
|
137
|
-
|
|
138
|
-
|
|
147
|
+
return (_jsxs(_Fragment, { children: [_jsxs("div", { className: "bg-muted/50 border-b border-border text-base flex flex-row flex-wrap items-center gap-0.5 p-1.5", children: [_jsx(EditorButton, { editor: editor, action: "toggleBold", isActive: editor.isActive('bold'), label: _jsx(FaBold, {}), disabled: true, title: labels.bold }), _jsx(EditorButton, { editor: editor, action: "toggleItalic", isActive: editor.isActive('italic'), label: _jsx(FaItalic, {}), disabled: true, title: labels.italic }), _jsx(EditorButton, { editor: editor, action: "toggleStrike", isActive: editor.isActive('strike'), label: _jsx(FaStrikethrough, {}), disabled: true, title: labels.strike }), _jsx(EditorButton, { editor: editor, action: "toggleCode", isActive: editor.isActive('code'), label: _jsx(FaCode, {}), disabled: true, title: labels.code }), _jsx(EditorButton, { editor: editor, action: "setParagraph", isActive: editor.isActive('paragraph'), label: _jsx(FaParagraph, {}), title: labels.paragraph }), _jsx(EditorButton, { editor: editor, action: "setHeading1", isActive: editor.isActive('heading', { level: 1 }), label: _jsx(LuHeading1, { size: "24px" }), title: labels.heading1 }), _jsx(EditorButton, { editor: editor, action: "setHeading2", isActive: editor.isActive('heading', { level: 2 }), label: _jsx(LuHeading2, { size: "24px" }), title: labels.heading2 }), _jsx(EditorButton, { editor: editor, action: "setHeading3", isActive: editor.isActive('heading', { level: 3 }), label: _jsx(LuHeading3, { size: "24px" }), title: labels.heading3 }), _jsx(EditorButton, { editor: editor, action: "toggleBulletList", isActive: editor.isActive('bulletList'), label: _jsx(AiOutlineUnorderedList, { size: "24px" }), title: labels.bulletList }), _jsx(EditorButton, { editor: editor, action: "toggleOrderedList", isActive: editor.isActive('orderedList'), label: _jsx(GoListOrdered, { size: "24px" }), title: labels.orderedList }), _jsx(EditorButton, { editor: editor, action: "toggleCodeBlock", isActive: editor.isActive('codeBlock'), label: _jsx(PiCodeBlock, { size: "24px" }), title: labels.codeBlock }), _jsx(EditorButton, { editor: editor, action: "toggleBlockquote", isActive: editor.isActive('blockquote'), label: _jsx(TbBlockquote, { size: "24px" }), title: labels.blockquote }), _jsx("div", { className: "w-px h-5 bg-border mx-0.5" }), _jsx("button", { type: "button", onClick: () => {
|
|
148
|
+
if (isLink) {
|
|
149
|
+
editor.chain().focus().unsetLink().run();
|
|
150
|
+
}
|
|
151
|
+
else {
|
|
152
|
+
toggle('link');
|
|
153
|
+
}
|
|
154
|
+
}, title: isLink ? labels.unsetLink : labels.setLink, className: 'w-8 h-8 flex items-center justify-center rounded-md transition-colors duration-150 ' +
|
|
155
|
+
(isLink
|
|
156
|
+
? 'bg-primary/10 text-primary dark:bg-primary/20 dark:text-primary-foreground hover:bg-destructive/10 hover:text-destructive'
|
|
157
|
+
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground'), children: _jsx(LuLink, { size: 18 }) }), _jsx("button", { type: "button", onClick: () => toggle('youtube'), className: tableBtnClass, title: labels.addYoutube, children: _jsx(TbBrandYoutube, { size: 18 }) }), uploadImage && (_jsx("button", { type: "button", onClick: () => triggerImageUpload(uploadImage, editor), className: tableBtnClass, title: labels.insertImage, children: _jsx(TbPhoto, { size: 18 }) })), _jsx("div", { className: "w-px h-5 bg-border mx-0.5" }), _jsx("button", { type: "button", onClick: () => editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run(), className: tableBtnClass, title: labels.insertTable, children: _jsx(TbTable, { size: 18 }) }), inTable && (_jsxs(_Fragment, { children: [_jsx("button", { type: "button", onClick: () => editor.chain().focus().addColumnAfter().run(), className: tableBtnClass, title: labels.addColumnAfter, children: _jsx(TbColumnInsertRight, { size: 18 }) }), _jsx("button", { type: "button", onClick: () => editor.chain().focus().addRowAfter().run(), className: tableBtnClass, title: labels.addRowAfter, children: _jsx(TbRowInsertBottom, { size: 18 }) }), _jsx("button", { type: "button", onClick: () => editor.chain().focus().deleteColumn().run(), className: tableBtnClass, title: labels.deleteColumn, children: _jsx(TbColumnRemove, { size: 18 }) }), _jsx("button", { type: "button", onClick: () => editor.chain().focus().deleteRow().run(), className: tableBtnClass, title: labels.deleteRow, children: _jsx(TbRowRemove, { size: 18 }) }), _jsx("button", { type: "button", onClick: () => editor.chain().focus().mergeOrSplit().run(), className: tableBtnClass, title: labels.mergeOrSplit, children: _jsx(TbArrowMergeBoth, { size: 18 }) })] })), _jsxs("div", { className: 'ml-auto flex items-center gap-0.5 ' + (isFullscreen ? 'pr-10' : ''), children: [hasSelection && (_jsx("button", { type: "button", onClick: () => toggle('transform'), className: tableBtnClass + (activePanel === 'transform' ? ' bg-primary/10 text-primary' : ''), title: labels.transformSelection, children: _jsx(LuWand, { size: 16 }) })), _jsx("button", { type: "button", onClick: () => toggle('markdown'), className: tableBtnClass, title: labels.appendMarkdown, children: _jsx(LuClipboardPaste, { size: 18, style: { transform: 'scaleX(-1)' } }) }), _jsx("button", { type: "button", onClick: onCopy, title: "Copy as Markdown", className: tableBtnClass, children: copied ? _jsx(LuCheck, { size: 16, className: "text-green-500" }) : _jsx(LuCopy, { size: 16 }) }), _jsx("button", { type: "button", onClick: onToggleFullscreen, title: isFullscreen ? 'Exit fullscreen' : 'Fullscreen', className: tableBtnClass, children: isFullscreen ? _jsx(LuMinimize2, { size: 16 }) : _jsx(LuMaximize2, { size: 16 }) })] })] }), _jsx(InlinePanel, { panel: activePanel, onClose: () => !isTransforming && setActivePanel(null), editor: editor, onUpdate: onUpdate, labels: labels, onTransform: (prompt) => __awaiter(void 0, void 0, void 0, function* () {
|
|
158
|
+
yield onTransform(prompt);
|
|
159
|
+
setActivePanel(null);
|
|
160
|
+
}), isTransforming: isTransforming })] }));
|
|
139
161
|
};
|
|
140
162
|
const DEFAULT_LABELS = {
|
|
141
163
|
bold: 'Bold',
|
|
@@ -170,14 +192,20 @@ const DEFAULT_LABELS = {
|
|
|
170
192
|
appendMarkdownTitle: 'Append Markdown',
|
|
171
193
|
appendMarkdownPlaceholder: 'Paste markdown here…',
|
|
172
194
|
appendMarkdownConfirm: 'Append',
|
|
195
|
+
transformSelection: 'Transform selection with AI',
|
|
196
|
+
transformSelectionTitle: 'Transform selected text with AI',
|
|
197
|
+
transformSelectionPlaceholder: 'e.g. "Fix grammar", "Make more formal", "Translate to Spanish"…',
|
|
198
|
+
transformSelectionConfirm: 'Transform',
|
|
173
199
|
cancel: 'Cancel',
|
|
174
200
|
};
|
|
175
201
|
export const MarkdownEditor = ({ content, editable, className, onUpdate, labels, onContentClick, }) => {
|
|
176
|
-
const { storage } = useRimori();
|
|
202
|
+
const { storage, ai } = useRimori();
|
|
177
203
|
const mergedLabels = Object.assign(Object.assign({}, DEFAULT_LABELS), labels);
|
|
178
204
|
const lastEmittedRef = useRef(content !== null && content !== void 0 ? content : '');
|
|
179
205
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
|
180
206
|
const [copied, setCopied] = useState(false);
|
|
207
|
+
const [hasSelection, setHasSelection] = useState(false);
|
|
208
|
+
const [isTransforming, setIsTransforming] = useState(false);
|
|
181
209
|
const stableUpload = useCallback((pngBlob) => __awaiter(void 0, void 0, void 0, function* () {
|
|
182
210
|
const { data, error } = yield storage.uploadImage(pngBlob);
|
|
183
211
|
if (error)
|
|
@@ -209,11 +237,41 @@ export const MarkdownEditor = ({ content, editable, className, onUpdate, labels,
|
|
|
209
237
|
lastEmittedRef.current = markdown;
|
|
210
238
|
onUpdate === null || onUpdate === void 0 ? void 0 : onUpdate(markdown);
|
|
211
239
|
},
|
|
240
|
+
onSelectionUpdate: ({ editor: ed }) => {
|
|
241
|
+
const { from, to } = ed.state.selection;
|
|
242
|
+
setHasSelection(from !== to);
|
|
243
|
+
},
|
|
212
244
|
});
|
|
245
|
+
const handleTransform = useCallback((prompt) => __awaiter(void 0, void 0, void 0, function* () {
|
|
246
|
+
if (!editor)
|
|
247
|
+
return;
|
|
248
|
+
const { from, to } = editor.state.selection;
|
|
249
|
+
if (from === to)
|
|
250
|
+
return;
|
|
251
|
+
const selectedText = editor.state.doc.textBetween(from, to, '\n');
|
|
252
|
+
setIsTransforming(true);
|
|
253
|
+
const transformed = yield ai.getText([
|
|
254
|
+
{
|
|
255
|
+
role: 'system',
|
|
256
|
+
content: 'You are a text editor assistant. Transform the provided text according to the user instruction. Return only the transformed text, no explanations. If formattation is wished, use markdown syntax in the output.',
|
|
257
|
+
},
|
|
258
|
+
{
|
|
259
|
+
role: 'user',
|
|
260
|
+
content: `Instruction: ${prompt}\n\nText:\n${selectedText}`,
|
|
261
|
+
},
|
|
262
|
+
]);
|
|
263
|
+
setIsTransforming(false);
|
|
264
|
+
if (!transformed)
|
|
265
|
+
return;
|
|
266
|
+
editor.chain().focus().deleteRange({ from, to }).insertContentAt(from, transformed).run();
|
|
267
|
+
const markdown = getMarkdown(editor);
|
|
268
|
+
lastEmittedRef.current = markdown;
|
|
269
|
+
onUpdate === null || onUpdate === void 0 ? void 0 : onUpdate(markdown);
|
|
270
|
+
}), [editor, ai, onUpdate]);
|
|
213
271
|
const handleCopy = useCallback(() => {
|
|
214
272
|
if (!editor)
|
|
215
273
|
return;
|
|
216
|
-
navigator.clipboard.writeText(getMarkdown(editor)).then(() => {
|
|
274
|
+
void navigator.clipboard.writeText(getMarkdown(editor)).then(() => {
|
|
217
275
|
setCopied(true);
|
|
218
276
|
setTimeout(() => setCopied(false), 2000);
|
|
219
277
|
});
|
|
@@ -241,5 +299,5 @@ export const MarkdownEditor = ({ content, editable, className, onUpdate, labels,
|
|
|
241
299
|
(editable ? 'border border-border bg-card shadow-sm' : 'bg-transparent') +
|
|
242
300
|
' ' +
|
|
243
301
|
(className !== null && className !== void 0 ? className : '');
|
|
244
|
-
return (_jsxs("div", { className: wrapperClass, onClick: onContentClick, children: [editor && editable && (_jsx(MenuBar, { editor: editor, onUpdate: onUpdate !== null && onUpdate !== void 0 ? onUpdate : (() => { }), uploadImage: stableUpload, labels: mergedLabels, onCopy: handleCopy, copied: copied, isFullscreen: isFullscreen, onToggleFullscreen: () => setIsFullscreen((v) => !v) })), _jsx(EditorContent, { editor: editor, className: isFullscreen ? 'flex-1 overflow-y-auto' : '' })] }));
|
|
302
|
+
return (_jsxs("div", { className: wrapperClass, onClick: onContentClick, children: [editor && editable && (_jsx(MenuBar, { editor: editor, onUpdate: onUpdate !== null && onUpdate !== void 0 ? onUpdate : (() => { }), uploadImage: stableUpload, labels: mergedLabels, onCopy: handleCopy, copied: copied, isFullscreen: isFullscreen, onToggleFullscreen: () => setIsFullscreen((v) => !v), hasSelection: hasSelection, onTransform: handleTransform, isTransforming: isTransforming })), _jsx(EditorContent, { editor: editor, className: isFullscreen ? 'flex-1 overflow-y-auto' : '' })] }));
|
|
245
303
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rimori/react-client",
|
|
3
|
-
"version": "0.4.12-next.
|
|
3
|
+
"version": "0.4.12-next.2",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -24,15 +24,15 @@
|
|
|
24
24
|
"format": "prettier --write ."
|
|
25
25
|
},
|
|
26
26
|
"peerDependencies": {
|
|
27
|
-
"@rimori/client": "
|
|
27
|
+
"@rimori/client": "2.5.23-next.1",
|
|
28
28
|
"react": "^18.1.0",
|
|
29
29
|
"react-dom": "^18.1.0"
|
|
30
30
|
},
|
|
31
31
|
"dependencies": {
|
|
32
32
|
"@tiptap/core": "^2.26.1",
|
|
33
|
-
"@tiptap/extension-paragraph": "^2.26.1",
|
|
34
33
|
"@tiptap/extension-image": "^2.26.1",
|
|
35
34
|
"@tiptap/extension-link": "^2.26.1",
|
|
35
|
+
"@tiptap/extension-paragraph": "^2.26.1",
|
|
36
36
|
"@tiptap/extension-table": "^2.26.1",
|
|
37
37
|
"@tiptap/extension-table-cell": "^2.26.1",
|
|
38
38
|
"@tiptap/extension-table-header": "^2.26.1",
|
|
@@ -47,7 +47,7 @@
|
|
|
47
47
|
},
|
|
48
48
|
"devDependencies": {
|
|
49
49
|
"@eslint/js": "^9.37.0",
|
|
50
|
-
"@rimori/client": "
|
|
50
|
+
"@rimori/client": "2.5.23-next.1",
|
|
51
51
|
"@types/react": "^18.3.21",
|
|
52
52
|
"eslint-config-prettier": "^10.1.8",
|
|
53
53
|
"eslint-plugin-prettier": "^5.5.4",
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { JSX, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
2
|
+
import { LuWand } from 'react-icons/lu';
|
|
2
3
|
import { useRimori } from '../../providers/PluginProvider';
|
|
3
4
|
import { Markdown } from 'tiptap-markdown';
|
|
4
5
|
import StarterKit from '@tiptap/starter-kit';
|
|
@@ -31,11 +32,11 @@ import {
|
|
|
31
32
|
LuHeading2,
|
|
32
33
|
LuHeading3,
|
|
33
34
|
LuLink,
|
|
34
|
-
LuUnlink,
|
|
35
35
|
LuCopy,
|
|
36
36
|
LuCheck,
|
|
37
37
|
LuMaximize2,
|
|
38
38
|
LuMinimize2,
|
|
39
|
+
LuLoader,
|
|
39
40
|
} from 'react-icons/lu';
|
|
40
41
|
import { FaBold, FaCode, FaItalic, FaParagraph, FaStrikethrough } from 'react-icons/fa';
|
|
41
42
|
import { ImageUploadExtension, triggerImageUpload } from './ImageUploadExtension';
|
|
@@ -125,7 +126,7 @@ const EditorButton = ({ editor, action, isActive, label, disabled, title }: Edit
|
|
|
125
126
|
// Inline panels (no Radix required)
|
|
126
127
|
// ---------------------------------------------------------------------------
|
|
127
128
|
|
|
128
|
-
type PanelType = 'link' | 'youtube' | 'markdown' | null;
|
|
129
|
+
type PanelType = 'link' | 'youtube' | 'markdown' | 'transform' | null;
|
|
129
130
|
|
|
130
131
|
interface InlinePanelProps {
|
|
131
132
|
panel: PanelType;
|
|
@@ -133,9 +134,19 @@ interface InlinePanelProps {
|
|
|
133
134
|
editor: Editor;
|
|
134
135
|
onUpdate: (content: string) => void;
|
|
135
136
|
labels: Required<EditorLabels>;
|
|
137
|
+
onTransform?: (prompt: string) => Promise<void>;
|
|
138
|
+
isTransforming?: boolean;
|
|
136
139
|
}
|
|
137
140
|
|
|
138
|
-
const InlinePanel = ({
|
|
141
|
+
const InlinePanel = ({
|
|
142
|
+
panel,
|
|
143
|
+
onClose,
|
|
144
|
+
editor,
|
|
145
|
+
onUpdate,
|
|
146
|
+
labels,
|
|
147
|
+
onTransform,
|
|
148
|
+
isTransforming,
|
|
149
|
+
}: InlinePanelProps): JSX.Element | null => {
|
|
139
150
|
const [value, setValue] = useState('');
|
|
140
151
|
|
|
141
152
|
// Reset value when panel changes
|
|
@@ -167,6 +178,9 @@ const InlinePanel = ({ panel, onClose, editor, onUpdate, labels }: InlinePanelPr
|
|
|
167
178
|
editor.commands.focus('end');
|
|
168
179
|
onUpdate(combined);
|
|
169
180
|
onClose();
|
|
181
|
+
} else if (panel === 'transform') {
|
|
182
|
+
if (!value.trim() || !onTransform) return;
|
|
183
|
+
onTransform(value.trim());
|
|
170
184
|
}
|
|
171
185
|
};
|
|
172
186
|
|
|
@@ -176,6 +190,7 @@ const InlinePanel = ({ panel, onClose, editor, onUpdate, labels }: InlinePanelPr
|
|
|
176
190
|
};
|
|
177
191
|
|
|
178
192
|
const isMarkdown = panel === 'markdown';
|
|
193
|
+
const isTransform = panel === 'transform';
|
|
179
194
|
|
|
180
195
|
return (
|
|
181
196
|
<div className="bg-muted/80 border-b border-border px-2 py-1.5 flex flex-col gap-1.5">
|
|
@@ -184,16 +199,18 @@ const InlinePanel = ({ panel, onClose, editor, onUpdate, labels }: InlinePanelPr
|
|
|
184
199
|
? labels.setLinkTitle
|
|
185
200
|
: panel === 'youtube'
|
|
186
201
|
? labels.addYoutubeTitle
|
|
187
|
-
:
|
|
202
|
+
: panel === 'transform'
|
|
203
|
+
? labels.transformSelectionTitle
|
|
204
|
+
: labels.appendMarkdownTitle}
|
|
188
205
|
</p>
|
|
189
|
-
{isMarkdown ? (
|
|
206
|
+
{isMarkdown || isTransform ? (
|
|
190
207
|
<textarea
|
|
191
208
|
autoFocus
|
|
192
|
-
rows={4}
|
|
209
|
+
rows={isTransform ? 2 : 4}
|
|
193
210
|
value={value}
|
|
194
211
|
onChange={(e) => setValue(e.target.value)}
|
|
195
212
|
onKeyDown={(e) => e.key === 'Escape' && onClose()}
|
|
196
|
-
placeholder={labels.appendMarkdownPlaceholder}
|
|
213
|
+
placeholder={isTransform ? labels.transformSelectionPlaceholder : labels.appendMarkdownPlaceholder}
|
|
197
214
|
className="w-full text-sm font-mono rounded border border-border bg-background px-2 py-1 resize-y outline-none focus:ring-1 focus:ring-ring"
|
|
198
215
|
/>
|
|
199
216
|
) : (
|
|
@@ -211,21 +228,25 @@ const InlinePanel = ({ panel, onClose, editor, onUpdate, labels }: InlinePanelPr
|
|
|
211
228
|
<button
|
|
212
229
|
type="button"
|
|
213
230
|
onClick={onClose}
|
|
214
|
-
|
|
231
|
+
disabled={isTransforming}
|
|
232
|
+
className="text-xs px-3 py-1 rounded border border-border hover:bg-accent transition-colors disabled:opacity-40"
|
|
215
233
|
>
|
|
216
234
|
{labels.cancel}
|
|
217
235
|
</button>
|
|
218
236
|
<button
|
|
219
237
|
type="button"
|
|
220
238
|
onClick={handleConfirm}
|
|
221
|
-
disabled={!value.trim() || (panel === 'link' && value.trim() === 'https://')}
|
|
222
|
-
className="text-xs px-3 py-1 rounded bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-40 transition-colors"
|
|
239
|
+
disabled={!value.trim() || (panel === 'link' && value.trim() === 'https://') || isTransforming}
|
|
240
|
+
className="text-xs px-3 py-1 rounded bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-40 transition-colors flex items-center gap-1"
|
|
223
241
|
>
|
|
242
|
+
{isTransforming && <LuLoader size={12} className="animate-spin" />}
|
|
224
243
|
{panel === 'link'
|
|
225
244
|
? labels.setLinkConfirm
|
|
226
245
|
: panel === 'youtube'
|
|
227
246
|
? labels.addYoutubeConfirm
|
|
228
|
-
:
|
|
247
|
+
: panel === 'transform'
|
|
248
|
+
? labels.transformSelectionConfirm
|
|
249
|
+
: labels.appendMarkdownConfirm}
|
|
229
250
|
</button>
|
|
230
251
|
</div>
|
|
231
252
|
</div>
|
|
@@ -245,6 +266,9 @@ interface MenuBarProps {
|
|
|
245
266
|
copied: boolean;
|
|
246
267
|
isFullscreen: boolean;
|
|
247
268
|
onToggleFullscreen: () => void;
|
|
269
|
+
hasSelection: boolean;
|
|
270
|
+
onTransform: (prompt: string) => Promise<void>;
|
|
271
|
+
isTransforming: boolean;
|
|
248
272
|
}
|
|
249
273
|
|
|
250
274
|
const MenuBar = ({
|
|
@@ -256,6 +280,9 @@ const MenuBar = ({
|
|
|
256
280
|
copied,
|
|
257
281
|
isFullscreen,
|
|
258
282
|
onToggleFullscreen,
|
|
283
|
+
hasSelection,
|
|
284
|
+
onTransform,
|
|
285
|
+
isTransforming,
|
|
259
286
|
}: MenuBarProps): JSX.Element => {
|
|
260
287
|
const [activePanel, setActivePanel] = useState<PanelType>(null);
|
|
261
288
|
|
|
@@ -369,30 +396,26 @@ const MenuBar = ({
|
|
|
369
396
|
|
|
370
397
|
<div className="w-px h-5 bg-border mx-0.5" />
|
|
371
398
|
|
|
372
|
-
{/* Link
|
|
373
|
-
<
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
'
|
|
380
|
-
'text-muted-foreground hover:bg-accent hover:text-accent-foreground border-r border-border last:border-r-0' +
|
|
381
|
-
(isLink ? ' bg-primary/10 text-primary dark:bg-primary/20 dark:text-primary-foreground' : '')
|
|
399
|
+
{/* Link button — opens panel when no link active, removes link when active */}
|
|
400
|
+
<button
|
|
401
|
+
type="button"
|
|
402
|
+
onClick={() => {
|
|
403
|
+
if (isLink) {
|
|
404
|
+
editor.chain().focus().unsetLink().run();
|
|
405
|
+
} else {
|
|
406
|
+
toggle('link');
|
|
382
407
|
}
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
</button>
|
|
395
|
-
</div>
|
|
408
|
+
}}
|
|
409
|
+
title={isLink ? labels.unsetLink : labels.setLink}
|
|
410
|
+
className={
|
|
411
|
+
'w-8 h-8 flex items-center justify-center rounded-md transition-colors duration-150 ' +
|
|
412
|
+
(isLink
|
|
413
|
+
? 'bg-primary/10 text-primary dark:bg-primary/20 dark:text-primary-foreground hover:bg-destructive/10 hover:text-destructive'
|
|
414
|
+
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground')
|
|
415
|
+
}
|
|
416
|
+
>
|
|
417
|
+
<LuLink size={18} />
|
|
418
|
+
</button>
|
|
396
419
|
|
|
397
420
|
{/* YouTube */}
|
|
398
421
|
<button type="button" onClick={() => toggle('youtube')} className={tableBtnClass} title={labels.addYoutube}>
|
|
@@ -422,65 +445,72 @@ const MenuBar = ({
|
|
|
422
445
|
>
|
|
423
446
|
<TbTable size={18} />
|
|
424
447
|
</button>
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
</button>
|
|
470
|
-
|
|
471
|
-
<div className="w-px h-5 bg-border mx-0.5" />
|
|
472
|
-
|
|
473
|
-
{/* Append raw markdown */}
|
|
474
|
-
<button
|
|
475
|
-
type="button"
|
|
476
|
-
onClick={() => toggle('markdown')}
|
|
477
|
-
className={tableBtnClass}
|
|
478
|
-
title={labels.appendMarkdown}
|
|
479
|
-
>
|
|
480
|
-
<LuClipboardPaste size={18} style={{ transform: 'scaleX(-1)' }} />
|
|
481
|
-
</button>
|
|
448
|
+
{inTable && (
|
|
449
|
+
<>
|
|
450
|
+
<button
|
|
451
|
+
type="button"
|
|
452
|
+
onClick={() => editor.chain().focus().addColumnAfter().run()}
|
|
453
|
+
className={tableBtnClass}
|
|
454
|
+
title={labels.addColumnAfter}
|
|
455
|
+
>
|
|
456
|
+
<TbColumnInsertRight size={18} />
|
|
457
|
+
</button>
|
|
458
|
+
<button
|
|
459
|
+
type="button"
|
|
460
|
+
onClick={() => editor.chain().focus().addRowAfter().run()}
|
|
461
|
+
className={tableBtnClass}
|
|
462
|
+
title={labels.addRowAfter}
|
|
463
|
+
>
|
|
464
|
+
<TbRowInsertBottom size={18} />
|
|
465
|
+
</button>
|
|
466
|
+
<button
|
|
467
|
+
type="button"
|
|
468
|
+
onClick={() => editor.chain().focus().deleteColumn().run()}
|
|
469
|
+
className={tableBtnClass}
|
|
470
|
+
title={labels.deleteColumn}
|
|
471
|
+
>
|
|
472
|
+
<TbColumnRemove size={18} />
|
|
473
|
+
</button>
|
|
474
|
+
<button
|
|
475
|
+
type="button"
|
|
476
|
+
onClick={() => editor.chain().focus().deleteRow().run()}
|
|
477
|
+
className={tableBtnClass}
|
|
478
|
+
title={labels.deleteRow}
|
|
479
|
+
>
|
|
480
|
+
<TbRowRemove size={18} />
|
|
481
|
+
</button>
|
|
482
|
+
<button
|
|
483
|
+
type="button"
|
|
484
|
+
onClick={() => editor.chain().focus().mergeOrSplit().run()}
|
|
485
|
+
className={tableBtnClass}
|
|
486
|
+
title={labels.mergeOrSplit}
|
|
487
|
+
>
|
|
488
|
+
<TbArrowMergeBoth size={18} />
|
|
489
|
+
</button>
|
|
490
|
+
</>
|
|
491
|
+
)}
|
|
482
492
|
|
|
483
493
|
<div className={'ml-auto flex items-center gap-0.5 ' + (isFullscreen ? 'pr-10' : '')}>
|
|
494
|
+
{/* Transform selection (only when text is selected) */}
|
|
495
|
+
{hasSelection && (
|
|
496
|
+
<button
|
|
497
|
+
type="button"
|
|
498
|
+
onClick={() => toggle('transform')}
|
|
499
|
+
className={tableBtnClass + (activePanel === 'transform' ? ' bg-primary/10 text-primary' : '')}
|
|
500
|
+
title={labels.transformSelection}
|
|
501
|
+
>
|
|
502
|
+
<LuWand size={16} />
|
|
503
|
+
</button>
|
|
504
|
+
)}
|
|
505
|
+
{/* Append raw markdown */}
|
|
506
|
+
<button
|
|
507
|
+
type="button"
|
|
508
|
+
onClick={() => toggle('markdown')}
|
|
509
|
+
className={tableBtnClass}
|
|
510
|
+
title={labels.appendMarkdown}
|
|
511
|
+
>
|
|
512
|
+
<LuClipboardPaste size={18} style={{ transform: 'scaleX(-1)' }} />
|
|
513
|
+
</button>
|
|
484
514
|
<button type="button" onClick={onCopy} title="Copy as Markdown" className={tableBtnClass}>
|
|
485
515
|
{copied ? <LuCheck size={16} className="text-green-500" /> : <LuCopy size={16} />}
|
|
486
516
|
</button>
|
|
@@ -497,10 +527,15 @@ const MenuBar = ({
|
|
|
497
527
|
|
|
498
528
|
<InlinePanel
|
|
499
529
|
panel={activePanel}
|
|
500
|
-
onClose={() => setActivePanel(null)}
|
|
530
|
+
onClose={() => !isTransforming && setActivePanel(null)}
|
|
501
531
|
editor={editor}
|
|
502
532
|
onUpdate={onUpdate}
|
|
503
533
|
labels={labels}
|
|
534
|
+
onTransform={async (prompt) => {
|
|
535
|
+
await onTransform(prompt);
|
|
536
|
+
setActivePanel(null);
|
|
537
|
+
}}
|
|
538
|
+
isTransforming={isTransforming}
|
|
504
539
|
/>
|
|
505
540
|
</>
|
|
506
541
|
);
|
|
@@ -543,6 +578,10 @@ export interface EditorLabels {
|
|
|
543
578
|
appendMarkdownTitle?: string;
|
|
544
579
|
appendMarkdownPlaceholder?: string;
|
|
545
580
|
appendMarkdownConfirm?: string;
|
|
581
|
+
transformSelection?: string;
|
|
582
|
+
transformSelectionTitle?: string;
|
|
583
|
+
transformSelectionPlaceholder?: string;
|
|
584
|
+
transformSelectionConfirm?: string;
|
|
546
585
|
cancel?: string;
|
|
547
586
|
}
|
|
548
587
|
|
|
@@ -579,6 +618,10 @@ const DEFAULT_LABELS: Required<EditorLabels> = {
|
|
|
579
618
|
appendMarkdownTitle: 'Append Markdown',
|
|
580
619
|
appendMarkdownPlaceholder: 'Paste markdown here…',
|
|
581
620
|
appendMarkdownConfirm: 'Append',
|
|
621
|
+
transformSelection: 'Transform selection with AI',
|
|
622
|
+
transformSelectionTitle: 'Transform selected text with AI',
|
|
623
|
+
transformSelectionPlaceholder: 'e.g. "Fix grammar", "Make more formal", "Translate to Spanish"…',
|
|
624
|
+
transformSelectionConfirm: 'Transform',
|
|
582
625
|
cancel: 'Cancel',
|
|
583
626
|
};
|
|
584
627
|
|
|
@@ -605,11 +648,13 @@ export const MarkdownEditor = ({
|
|
|
605
648
|
labels,
|
|
606
649
|
onContentClick,
|
|
607
650
|
}: MarkdownEditorProps): JSX.Element => {
|
|
608
|
-
const { storage } = useRimori();
|
|
651
|
+
const { storage, ai } = useRimori();
|
|
609
652
|
const mergedLabels: Required<EditorLabels> = { ...DEFAULT_LABELS, ...labels };
|
|
610
653
|
const lastEmittedRef = useRef(content ?? '');
|
|
611
654
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
|
612
655
|
const [copied, setCopied] = useState(false);
|
|
656
|
+
const [hasSelection, setHasSelection] = useState(false);
|
|
657
|
+
const [isTransforming, setIsTransforming] = useState(false);
|
|
613
658
|
|
|
614
659
|
const stableUpload = useCallback(
|
|
615
660
|
async (pngBlob: Blob): Promise<string | null> => {
|
|
@@ -649,11 +694,43 @@ export const MarkdownEditor = ({
|
|
|
649
694
|
lastEmittedRef.current = markdown;
|
|
650
695
|
onUpdate?.(markdown);
|
|
651
696
|
},
|
|
697
|
+
onSelectionUpdate: ({ editor: ed }) => {
|
|
698
|
+
const { from, to } = ed.state.selection;
|
|
699
|
+
setHasSelection(from !== to);
|
|
700
|
+
},
|
|
652
701
|
});
|
|
653
702
|
|
|
703
|
+
const handleTransform = useCallback(
|
|
704
|
+
async (prompt: string) => {
|
|
705
|
+
if (!editor) return;
|
|
706
|
+
const { from, to } = editor.state.selection;
|
|
707
|
+
if (from === to) return;
|
|
708
|
+
const selectedText = editor.state.doc.textBetween(from, to, '\n');
|
|
709
|
+
setIsTransforming(true);
|
|
710
|
+
const transformed = await ai.getText([
|
|
711
|
+
{
|
|
712
|
+
role: 'system',
|
|
713
|
+
content:
|
|
714
|
+
'You are a text editor assistant. Transform the provided text according to the user instruction. Return only the transformed text, no explanations. If formattation is wished, use markdown syntax in the output.',
|
|
715
|
+
},
|
|
716
|
+
{
|
|
717
|
+
role: 'user',
|
|
718
|
+
content: `Instruction: ${prompt}\n\nText:\n${selectedText}`,
|
|
719
|
+
},
|
|
720
|
+
]);
|
|
721
|
+
setIsTransforming(false);
|
|
722
|
+
if (!transformed) return;
|
|
723
|
+
editor.chain().focus().deleteRange({ from, to }).insertContentAt(from, transformed).run();
|
|
724
|
+
const markdown = getMarkdown(editor);
|
|
725
|
+
lastEmittedRef.current = markdown;
|
|
726
|
+
onUpdate?.(markdown);
|
|
727
|
+
},
|
|
728
|
+
[editor, ai, onUpdate],
|
|
729
|
+
);
|
|
730
|
+
|
|
654
731
|
const handleCopy = useCallback(() => {
|
|
655
732
|
if (!editor) return;
|
|
656
|
-
navigator.clipboard.writeText(getMarkdown(editor)).then(() => {
|
|
733
|
+
void navigator.clipboard.writeText(getMarkdown(editor)).then(() => {
|
|
657
734
|
setCopied(true);
|
|
658
735
|
setTimeout(() => setCopied(false), 2000);
|
|
659
736
|
});
|
|
@@ -694,6 +771,9 @@ export const MarkdownEditor = ({
|
|
|
694
771
|
copied={copied}
|
|
695
772
|
isFullscreen={isFullscreen}
|
|
696
773
|
onToggleFullscreen={() => setIsFullscreen((v) => !v)}
|
|
774
|
+
hasSelection={hasSelection}
|
|
775
|
+
onTransform={handleTransform}
|
|
776
|
+
isTransforming={isTransforming}
|
|
697
777
|
/>
|
|
698
778
|
)}
|
|
699
779
|
<EditorContent editor={editor} className={isFullscreen ? 'flex-1 overflow-y-auto' : ''} />
|