@rimori/react-client 0.4.12-next.1 → 0.4.12-next.3
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 { LuSparkles } 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 }), isTransform ? (_jsx("input", { autoFocus: true, type: "text", value: value, onChange: (e) => setValue(e.target.value), onKeyDown: handleKeyDown, placeholder: labels.transformSelectionPlaceholder, 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" })) : isMarkdown ? (_jsx("textarea", { autoFocus: true, rows: 4, value: value, onChange: (e) => setValue(e.target.value), onKeyDown: (e) => e.key === 'Escape' && onClose(), placeholder: 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(LuSparkles, { 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,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rimori/react-client",
|
|
3
|
-
"version": "0.4.12-next.
|
|
3
|
+
"version": "0.4.12-next.3",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
"scripts": {
|
|
20
20
|
"build": "tsc && sass src/style.scss:dist/style.css",
|
|
21
21
|
"dev": "tsc -w --preserveWatchOutput",
|
|
22
|
+
"dev:watch": "tsc -w --preserveWatchOutput",
|
|
22
23
|
"css-dev": "sass --watch src/style.scss:dist/style.css",
|
|
23
24
|
"lint": "eslint . --fix",
|
|
24
25
|
"format": "prettier --write ."
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { JSX, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
2
|
+
import { LuSparkles } 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,9 +199,21 @@ 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
|
-
{
|
|
206
|
+
{isTransform ? (
|
|
207
|
+
<input
|
|
208
|
+
autoFocus
|
|
209
|
+
type="text"
|
|
210
|
+
value={value}
|
|
211
|
+
onChange={(e) => setValue(e.target.value)}
|
|
212
|
+
onKeyDown={handleKeyDown}
|
|
213
|
+
placeholder={labels.transformSelectionPlaceholder}
|
|
214
|
+
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"
|
|
215
|
+
/>
|
|
216
|
+
) : isMarkdown ? (
|
|
189
217
|
<textarea
|
|
190
218
|
autoFocus
|
|
191
219
|
rows={4}
|
|
@@ -210,21 +238,25 @@ const InlinePanel = ({ panel, onClose, editor, onUpdate, labels }: InlinePanelPr
|
|
|
210
238
|
<button
|
|
211
239
|
type="button"
|
|
212
240
|
onClick={onClose}
|
|
213
|
-
|
|
241
|
+
disabled={isTransforming}
|
|
242
|
+
className="text-xs px-3 py-1 rounded border border-border hover:bg-accent transition-colors disabled:opacity-40"
|
|
214
243
|
>
|
|
215
244
|
{labels.cancel}
|
|
216
245
|
</button>
|
|
217
246
|
<button
|
|
218
247
|
type="button"
|
|
219
248
|
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"
|
|
249
|
+
disabled={!value.trim() || (panel === 'link' && value.trim() === 'https://') || isTransforming}
|
|
250
|
+
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
251
|
>
|
|
252
|
+
{isTransforming && <LuLoader size={12} className="animate-spin" />}
|
|
223
253
|
{panel === 'link'
|
|
224
254
|
? labels.setLinkConfirm
|
|
225
255
|
: panel === 'youtube'
|
|
226
256
|
? labels.addYoutubeConfirm
|
|
227
|
-
:
|
|
257
|
+
: panel === 'transform'
|
|
258
|
+
? labels.transformSelectionConfirm
|
|
259
|
+
: labels.appendMarkdownConfirm}
|
|
228
260
|
</button>
|
|
229
261
|
</div>
|
|
230
262
|
</div>
|
|
@@ -244,6 +276,9 @@ interface MenuBarProps {
|
|
|
244
276
|
copied: boolean;
|
|
245
277
|
isFullscreen: boolean;
|
|
246
278
|
onToggleFullscreen: () => void;
|
|
279
|
+
hasSelection: boolean;
|
|
280
|
+
onTransform: (prompt: string) => Promise<void>;
|
|
281
|
+
isTransforming: boolean;
|
|
247
282
|
}
|
|
248
283
|
|
|
249
284
|
const MenuBar = ({
|
|
@@ -255,6 +290,9 @@ const MenuBar = ({
|
|
|
255
290
|
copied,
|
|
256
291
|
isFullscreen,
|
|
257
292
|
onToggleFullscreen,
|
|
293
|
+
hasSelection,
|
|
294
|
+
onTransform,
|
|
295
|
+
isTransforming,
|
|
258
296
|
}: MenuBarProps): JSX.Element => {
|
|
259
297
|
const [activePanel, setActivePanel] = useState<PanelType>(null);
|
|
260
298
|
|
|
@@ -463,6 +501,17 @@ const MenuBar = ({
|
|
|
463
501
|
)}
|
|
464
502
|
|
|
465
503
|
<div className={'ml-auto flex items-center gap-0.5 ' + (isFullscreen ? 'pr-10' : '')}>
|
|
504
|
+
{/* Transform selection (only when text is selected) */}
|
|
505
|
+
{hasSelection && (
|
|
506
|
+
<button
|
|
507
|
+
type="button"
|
|
508
|
+
onClick={() => toggle('transform')}
|
|
509
|
+
className={tableBtnClass + (activePanel === 'transform' ? ' bg-primary/10 text-primary' : '')}
|
|
510
|
+
title={labels.transformSelection}
|
|
511
|
+
>
|
|
512
|
+
<LuSparkles size={16} />
|
|
513
|
+
</button>
|
|
514
|
+
)}
|
|
466
515
|
{/* Append raw markdown */}
|
|
467
516
|
<button
|
|
468
517
|
type="button"
|
|
@@ -488,10 +537,15 @@ const MenuBar = ({
|
|
|
488
537
|
|
|
489
538
|
<InlinePanel
|
|
490
539
|
panel={activePanel}
|
|
491
|
-
onClose={() => setActivePanel(null)}
|
|
540
|
+
onClose={() => !isTransforming && setActivePanel(null)}
|
|
492
541
|
editor={editor}
|
|
493
542
|
onUpdate={onUpdate}
|
|
494
543
|
labels={labels}
|
|
544
|
+
onTransform={async (prompt) => {
|
|
545
|
+
await onTransform(prompt);
|
|
546
|
+
setActivePanel(null);
|
|
547
|
+
}}
|
|
548
|
+
isTransforming={isTransforming}
|
|
495
549
|
/>
|
|
496
550
|
</>
|
|
497
551
|
);
|
|
@@ -534,6 +588,10 @@ export interface EditorLabels {
|
|
|
534
588
|
appendMarkdownTitle?: string;
|
|
535
589
|
appendMarkdownPlaceholder?: string;
|
|
536
590
|
appendMarkdownConfirm?: string;
|
|
591
|
+
transformSelection?: string;
|
|
592
|
+
transformSelectionTitle?: string;
|
|
593
|
+
transformSelectionPlaceholder?: string;
|
|
594
|
+
transformSelectionConfirm?: string;
|
|
537
595
|
cancel?: string;
|
|
538
596
|
}
|
|
539
597
|
|
|
@@ -570,6 +628,10 @@ const DEFAULT_LABELS: Required<EditorLabels> = {
|
|
|
570
628
|
appendMarkdownTitle: 'Append Markdown',
|
|
571
629
|
appendMarkdownPlaceholder: 'Paste markdown here…',
|
|
572
630
|
appendMarkdownConfirm: 'Append',
|
|
631
|
+
transformSelection: 'Transform selection with AI',
|
|
632
|
+
transformSelectionTitle: 'Transform selected text with AI',
|
|
633
|
+
transformSelectionPlaceholder: 'e.g. "Fix grammar", "Make more formal", "Translate to Spanish"…',
|
|
634
|
+
transformSelectionConfirm: 'Transform',
|
|
573
635
|
cancel: 'Cancel',
|
|
574
636
|
};
|
|
575
637
|
|
|
@@ -596,11 +658,13 @@ export const MarkdownEditor = ({
|
|
|
596
658
|
labels,
|
|
597
659
|
onContentClick,
|
|
598
660
|
}: MarkdownEditorProps): JSX.Element => {
|
|
599
|
-
const { storage } = useRimori();
|
|
661
|
+
const { storage, ai } = useRimori();
|
|
600
662
|
const mergedLabels: Required<EditorLabels> = { ...DEFAULT_LABELS, ...labels };
|
|
601
663
|
const lastEmittedRef = useRef(content ?? '');
|
|
602
664
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
|
603
665
|
const [copied, setCopied] = useState(false);
|
|
666
|
+
const [hasSelection, setHasSelection] = useState(false);
|
|
667
|
+
const [isTransforming, setIsTransforming] = useState(false);
|
|
604
668
|
|
|
605
669
|
const stableUpload = useCallback(
|
|
606
670
|
async (pngBlob: Blob): Promise<string | null> => {
|
|
@@ -640,11 +704,43 @@ export const MarkdownEditor = ({
|
|
|
640
704
|
lastEmittedRef.current = markdown;
|
|
641
705
|
onUpdate?.(markdown);
|
|
642
706
|
},
|
|
707
|
+
onSelectionUpdate: ({ editor: ed }) => {
|
|
708
|
+
const { from, to } = ed.state.selection;
|
|
709
|
+
setHasSelection(from !== to);
|
|
710
|
+
},
|
|
643
711
|
});
|
|
644
712
|
|
|
713
|
+
const handleTransform = useCallback(
|
|
714
|
+
async (prompt: string) => {
|
|
715
|
+
if (!editor) return;
|
|
716
|
+
const { from, to } = editor.state.selection;
|
|
717
|
+
if (from === to) return;
|
|
718
|
+
const selectedText = editor.state.doc.textBetween(from, to, '\n');
|
|
719
|
+
setIsTransforming(true);
|
|
720
|
+
const transformed = await ai.getText([
|
|
721
|
+
{
|
|
722
|
+
role: 'system',
|
|
723
|
+
content:
|
|
724
|
+
'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.',
|
|
725
|
+
},
|
|
726
|
+
{
|
|
727
|
+
role: 'user',
|
|
728
|
+
content: `Instruction: ${prompt}\n\nText:\n${selectedText}`,
|
|
729
|
+
},
|
|
730
|
+
]);
|
|
731
|
+
setIsTransforming(false);
|
|
732
|
+
if (!transformed) return;
|
|
733
|
+
editor.chain().focus().deleteRange({ from, to }).insertContentAt(from, transformed).run();
|
|
734
|
+
const markdown = getMarkdown(editor);
|
|
735
|
+
lastEmittedRef.current = markdown;
|
|
736
|
+
onUpdate?.(markdown);
|
|
737
|
+
},
|
|
738
|
+
[editor, ai, onUpdate],
|
|
739
|
+
);
|
|
740
|
+
|
|
645
741
|
const handleCopy = useCallback(() => {
|
|
646
742
|
if (!editor) return;
|
|
647
|
-
navigator.clipboard.writeText(getMarkdown(editor)).then(() => {
|
|
743
|
+
void navigator.clipboard.writeText(getMarkdown(editor)).then(() => {
|
|
648
744
|
setCopied(true);
|
|
649
745
|
setTimeout(() => setCopied(false), 2000);
|
|
650
746
|
});
|
|
@@ -685,6 +781,9 @@ export const MarkdownEditor = ({
|
|
|
685
781
|
copied={copied}
|
|
686
782
|
isFullscreen={isFullscreen}
|
|
687
783
|
onToggleFullscreen={() => setIsFullscreen((v) => !v)}
|
|
784
|
+
hasSelection={hasSelection}
|
|
785
|
+
onTransform={handleTransform}
|
|
786
|
+
isTransforming={isTransforming}
|
|
688
787
|
/>
|
|
689
788
|
)}
|
|
690
789
|
<EditorContent editor={editor} className={isFullscreen ? 'flex-1 overflow-y-auto' : ''} />
|