@rimori/react-client 0.4.12-next.1 → 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, LuCopy, LuCheck, LuMaximize2, LuMinimize2, } from 'react-icons/lu';
|
|
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,17 +122,22 @@ 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');
|
|
@@ -143,7 +154,10 @@ const MenuBar = ({ editor, onUpdate, uploadImage, labels, onCopy, copied, isFull
|
|
|
143
154
|
}, title: isLink ? labels.unsetLink : labels.setLink, className: 'w-8 h-8 flex items-center justify-center rounded-md transition-colors duration-150 ' +
|
|
144
155
|
(isLink
|
|
145
156
|
? 'bg-primary/10 text-primary dark:bg-primary/20 dark:text-primary-foreground hover:bg-destructive/10 hover:text-destructive'
|
|
146
|
-
: '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: [_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: () => setActivePanel(null), editor: editor, onUpdate: onUpdate, labels: labels
|
|
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 })] }));
|
|
147
161
|
};
|
|
148
162
|
const DEFAULT_LABELS = {
|
|
149
163
|
bold: 'Bold',
|
|
@@ -178,14 +192,20 @@ const DEFAULT_LABELS = {
|
|
|
178
192
|
appendMarkdownTitle: 'Append Markdown',
|
|
179
193
|
appendMarkdownPlaceholder: 'Paste markdown here…',
|
|
180
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',
|
|
181
199
|
cancel: 'Cancel',
|
|
182
200
|
};
|
|
183
201
|
export const MarkdownEditor = ({ content, editable, className, onUpdate, labels, onContentClick, }) => {
|
|
184
|
-
const { storage } = useRimori();
|
|
202
|
+
const { storage, ai } = useRimori();
|
|
185
203
|
const mergedLabels = Object.assign(Object.assign({}, DEFAULT_LABELS), labels);
|
|
186
204
|
const lastEmittedRef = useRef(content !== null && content !== void 0 ? content : '');
|
|
187
205
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
|
188
206
|
const [copied, setCopied] = useState(false);
|
|
207
|
+
const [hasSelection, setHasSelection] = useState(false);
|
|
208
|
+
const [isTransforming, setIsTransforming] = useState(false);
|
|
189
209
|
const stableUpload = useCallback((pngBlob) => __awaiter(void 0, void 0, void 0, function* () {
|
|
190
210
|
const { data, error } = yield storage.uploadImage(pngBlob);
|
|
191
211
|
if (error)
|
|
@@ -217,11 +237,41 @@ export const MarkdownEditor = ({ content, editable, className, onUpdate, labels,
|
|
|
217
237
|
lastEmittedRef.current = markdown;
|
|
218
238
|
onUpdate === null || onUpdate === void 0 ? void 0 : onUpdate(markdown);
|
|
219
239
|
},
|
|
240
|
+
onSelectionUpdate: ({ editor: ed }) => {
|
|
241
|
+
const { from, to } = ed.state.selection;
|
|
242
|
+
setHasSelection(from !== to);
|
|
243
|
+
},
|
|
220
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]);
|
|
221
271
|
const handleCopy = useCallback(() => {
|
|
222
272
|
if (!editor)
|
|
223
273
|
return;
|
|
224
|
-
navigator.clipboard.writeText(getMarkdown(editor)).then(() => {
|
|
274
|
+
void navigator.clipboard.writeText(getMarkdown(editor)).then(() => {
|
|
225
275
|
setCopied(true);
|
|
226
276
|
setTimeout(() => setCopied(false), 2000);
|
|
227
277
|
});
|
|
@@ -249,5 +299,5 @@ export const MarkdownEditor = ({ content, editable, className, onUpdate, labels,
|
|
|
249
299
|
(editable ? 'border border-border bg-card shadow-sm' : 'bg-transparent') +
|
|
250
300
|
' ' +
|
|
251
301
|
(className !== null && className !== void 0 ? className : '');
|
|
252
|
-
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' : '' })] }));
|
|
253
303
|
};
|
package/package.json
CHANGED
|
@@ -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';
|
|
@@ -35,6 +36,7 @@ import {
|
|
|
35
36
|
LuCheck,
|
|
36
37
|
LuMaximize2,
|
|
37
38
|
LuMinimize2,
|
|
39
|
+
LuLoader,
|
|
38
40
|
} from 'react-icons/lu';
|
|
39
41
|
import { FaBold, FaCode, FaItalic, FaParagraph, FaStrikethrough } from 'react-icons/fa';
|
|
40
42
|
import { ImageUploadExtension, triggerImageUpload } from './ImageUploadExtension';
|
|
@@ -124,7 +126,7 @@ const EditorButton = ({ editor, action, isActive, label, disabled, title }: Edit
|
|
|
124
126
|
// Inline panels (no Radix required)
|
|
125
127
|
// ---------------------------------------------------------------------------
|
|
126
128
|
|
|
127
|
-
type PanelType = 'link' | 'youtube' | 'markdown' | null;
|
|
129
|
+
type PanelType = 'link' | 'youtube' | 'markdown' | 'transform' | null;
|
|
128
130
|
|
|
129
131
|
interface InlinePanelProps {
|
|
130
132
|
panel: PanelType;
|
|
@@ -132,9 +134,19 @@ interface InlinePanelProps {
|
|
|
132
134
|
editor: Editor;
|
|
133
135
|
onUpdate: (content: string) => void;
|
|
134
136
|
labels: Required<EditorLabels>;
|
|
137
|
+
onTransform?: (prompt: string) => Promise<void>;
|
|
138
|
+
isTransforming?: boolean;
|
|
135
139
|
}
|
|
136
140
|
|
|
137
|
-
const InlinePanel = ({
|
|
141
|
+
const InlinePanel = ({
|
|
142
|
+
panel,
|
|
143
|
+
onClose,
|
|
144
|
+
editor,
|
|
145
|
+
onUpdate,
|
|
146
|
+
labels,
|
|
147
|
+
onTransform,
|
|
148
|
+
isTransforming,
|
|
149
|
+
}: InlinePanelProps): JSX.Element | null => {
|
|
138
150
|
const [value, setValue] = useState('');
|
|
139
151
|
|
|
140
152
|
// Reset value when panel changes
|
|
@@ -166,6 +178,9 @@ const InlinePanel = ({ panel, onClose, editor, onUpdate, labels }: InlinePanelPr
|
|
|
166
178
|
editor.commands.focus('end');
|
|
167
179
|
onUpdate(combined);
|
|
168
180
|
onClose();
|
|
181
|
+
} else if (panel === 'transform') {
|
|
182
|
+
if (!value.trim() || !onTransform) return;
|
|
183
|
+
onTransform(value.trim());
|
|
169
184
|
}
|
|
170
185
|
};
|
|
171
186
|
|
|
@@ -175,6 +190,7 @@ const InlinePanel = ({ panel, onClose, editor, onUpdate, labels }: InlinePanelPr
|
|
|
175
190
|
};
|
|
176
191
|
|
|
177
192
|
const isMarkdown = panel === 'markdown';
|
|
193
|
+
const isTransform = panel === 'transform';
|
|
178
194
|
|
|
179
195
|
return (
|
|
180
196
|
<div className="bg-muted/80 border-b border-border px-2 py-1.5 flex flex-col gap-1.5">
|
|
@@ -183,16 +199,18 @@ const InlinePanel = ({ panel, onClose, editor, onUpdate, labels }: InlinePanelPr
|
|
|
183
199
|
? labels.setLinkTitle
|
|
184
200
|
: panel === 'youtube'
|
|
185
201
|
? labels.addYoutubeTitle
|
|
186
|
-
:
|
|
202
|
+
: panel === 'transform'
|
|
203
|
+
? labels.transformSelectionTitle
|
|
204
|
+
: labels.appendMarkdownTitle}
|
|
187
205
|
</p>
|
|
188
|
-
{isMarkdown ? (
|
|
206
|
+
{isMarkdown || isTransform ? (
|
|
189
207
|
<textarea
|
|
190
208
|
autoFocus
|
|
191
|
-
rows={4}
|
|
209
|
+
rows={isTransform ? 2 : 4}
|
|
192
210
|
value={value}
|
|
193
211
|
onChange={(e) => setValue(e.target.value)}
|
|
194
212
|
onKeyDown={(e) => e.key === 'Escape' && onClose()}
|
|
195
|
-
placeholder={labels.appendMarkdownPlaceholder}
|
|
213
|
+
placeholder={isTransform ? labels.transformSelectionPlaceholder : labels.appendMarkdownPlaceholder}
|
|
196
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"
|
|
197
215
|
/>
|
|
198
216
|
) : (
|
|
@@ -210,21 +228,25 @@ const InlinePanel = ({ panel, onClose, editor, onUpdate, labels }: InlinePanelPr
|
|
|
210
228
|
<button
|
|
211
229
|
type="button"
|
|
212
230
|
onClick={onClose}
|
|
213
|
-
|
|
231
|
+
disabled={isTransforming}
|
|
232
|
+
className="text-xs px-3 py-1 rounded border border-border hover:bg-accent transition-colors disabled:opacity-40"
|
|
214
233
|
>
|
|
215
234
|
{labels.cancel}
|
|
216
235
|
</button>
|
|
217
236
|
<button
|
|
218
237
|
type="button"
|
|
219
238
|
onClick={handleConfirm}
|
|
220
|
-
disabled={!value.trim() || (panel === 'link' && value.trim() === 'https://')}
|
|
221
|
-
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"
|
|
222
241
|
>
|
|
242
|
+
{isTransforming && <LuLoader size={12} className="animate-spin" />}
|
|
223
243
|
{panel === 'link'
|
|
224
244
|
? labels.setLinkConfirm
|
|
225
245
|
: panel === 'youtube'
|
|
226
246
|
? labels.addYoutubeConfirm
|
|
227
|
-
:
|
|
247
|
+
: panel === 'transform'
|
|
248
|
+
? labels.transformSelectionConfirm
|
|
249
|
+
: labels.appendMarkdownConfirm}
|
|
228
250
|
</button>
|
|
229
251
|
</div>
|
|
230
252
|
</div>
|
|
@@ -244,6 +266,9 @@ interface MenuBarProps {
|
|
|
244
266
|
copied: boolean;
|
|
245
267
|
isFullscreen: boolean;
|
|
246
268
|
onToggleFullscreen: () => void;
|
|
269
|
+
hasSelection: boolean;
|
|
270
|
+
onTransform: (prompt: string) => Promise<void>;
|
|
271
|
+
isTransforming: boolean;
|
|
247
272
|
}
|
|
248
273
|
|
|
249
274
|
const MenuBar = ({
|
|
@@ -255,6 +280,9 @@ const MenuBar = ({
|
|
|
255
280
|
copied,
|
|
256
281
|
isFullscreen,
|
|
257
282
|
onToggleFullscreen,
|
|
283
|
+
hasSelection,
|
|
284
|
+
onTransform,
|
|
285
|
+
isTransforming,
|
|
258
286
|
}: MenuBarProps): JSX.Element => {
|
|
259
287
|
const [activePanel, setActivePanel] = useState<PanelType>(null);
|
|
260
288
|
|
|
@@ -463,6 +491,17 @@ const MenuBar = ({
|
|
|
463
491
|
)}
|
|
464
492
|
|
|
465
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
|
+
)}
|
|
466
505
|
{/* Append raw markdown */}
|
|
467
506
|
<button
|
|
468
507
|
type="button"
|
|
@@ -488,10 +527,15 @@ const MenuBar = ({
|
|
|
488
527
|
|
|
489
528
|
<InlinePanel
|
|
490
529
|
panel={activePanel}
|
|
491
|
-
onClose={() => setActivePanel(null)}
|
|
530
|
+
onClose={() => !isTransforming && setActivePanel(null)}
|
|
492
531
|
editor={editor}
|
|
493
532
|
onUpdate={onUpdate}
|
|
494
533
|
labels={labels}
|
|
534
|
+
onTransform={async (prompt) => {
|
|
535
|
+
await onTransform(prompt);
|
|
536
|
+
setActivePanel(null);
|
|
537
|
+
}}
|
|
538
|
+
isTransforming={isTransforming}
|
|
495
539
|
/>
|
|
496
540
|
</>
|
|
497
541
|
);
|
|
@@ -534,6 +578,10 @@ export interface EditorLabels {
|
|
|
534
578
|
appendMarkdownTitle?: string;
|
|
535
579
|
appendMarkdownPlaceholder?: string;
|
|
536
580
|
appendMarkdownConfirm?: string;
|
|
581
|
+
transformSelection?: string;
|
|
582
|
+
transformSelectionTitle?: string;
|
|
583
|
+
transformSelectionPlaceholder?: string;
|
|
584
|
+
transformSelectionConfirm?: string;
|
|
537
585
|
cancel?: string;
|
|
538
586
|
}
|
|
539
587
|
|
|
@@ -570,6 +618,10 @@ const DEFAULT_LABELS: Required<EditorLabels> = {
|
|
|
570
618
|
appendMarkdownTitle: 'Append Markdown',
|
|
571
619
|
appendMarkdownPlaceholder: 'Paste markdown here…',
|
|
572
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',
|
|
573
625
|
cancel: 'Cancel',
|
|
574
626
|
};
|
|
575
627
|
|
|
@@ -596,11 +648,13 @@ export const MarkdownEditor = ({
|
|
|
596
648
|
labels,
|
|
597
649
|
onContentClick,
|
|
598
650
|
}: MarkdownEditorProps): JSX.Element => {
|
|
599
|
-
const { storage } = useRimori();
|
|
651
|
+
const { storage, ai } = useRimori();
|
|
600
652
|
const mergedLabels: Required<EditorLabels> = { ...DEFAULT_LABELS, ...labels };
|
|
601
653
|
const lastEmittedRef = useRef(content ?? '');
|
|
602
654
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
|
603
655
|
const [copied, setCopied] = useState(false);
|
|
656
|
+
const [hasSelection, setHasSelection] = useState(false);
|
|
657
|
+
const [isTransforming, setIsTransforming] = useState(false);
|
|
604
658
|
|
|
605
659
|
const stableUpload = useCallback(
|
|
606
660
|
async (pngBlob: Blob): Promise<string | null> => {
|
|
@@ -640,11 +694,43 @@ export const MarkdownEditor = ({
|
|
|
640
694
|
lastEmittedRef.current = markdown;
|
|
641
695
|
onUpdate?.(markdown);
|
|
642
696
|
},
|
|
697
|
+
onSelectionUpdate: ({ editor: ed }) => {
|
|
698
|
+
const { from, to } = ed.state.selection;
|
|
699
|
+
setHasSelection(from !== to);
|
|
700
|
+
},
|
|
643
701
|
});
|
|
644
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
|
+
|
|
645
731
|
const handleCopy = useCallback(() => {
|
|
646
732
|
if (!editor) return;
|
|
647
|
-
navigator.clipboard.writeText(getMarkdown(editor)).then(() => {
|
|
733
|
+
void navigator.clipboard.writeText(getMarkdown(editor)).then(() => {
|
|
648
734
|
setCopied(true);
|
|
649
735
|
setTimeout(() => setCopied(false), 2000);
|
|
650
736
|
});
|
|
@@ -685,6 +771,9 @@ export const MarkdownEditor = ({
|
|
|
685
771
|
copied={copied}
|
|
686
772
|
isFullscreen={isFullscreen}
|
|
687
773
|
onToggleFullscreen={() => setIsFullscreen((v) => !v)}
|
|
774
|
+
hasSelection={hasSelection}
|
|
775
|
+
onTransform={handleTransform}
|
|
776
|
+
isTransforming={isTransforming}
|
|
688
777
|
/>
|
|
689
778
|
)}
|
|
690
779
|
<EditorContent editor={editor} className={isFullscreen ? 'flex-1 overflow-y-auto' : ''} />
|