@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
- : labels.appendMarkdownTitle }), 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, className: "text-xs px-3 py-1 rounded border border-border hover:bg-accent transition-colors", children: labels.cancel }), _jsx("button", { type: "button", onClick: handleConfirm, disabled: !value.trim() || (panel === 'link' && value.trim() === 'https://'), className: "text-xs px-3 py-1 rounded bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-40 transition-colors", children: panel === 'link'
124
- ? labels.setLinkConfirm
125
- : panel === 'youtube'
126
- ? labels.addYoutubeConfirm
127
- : labels.appendMarkdownConfirm })] })] }));
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,6 +1,6 @@
1
1
  {
2
2
  "name": "@rimori/react-client",
3
- "version": "0.4.12-next.1",
3
+ "version": "0.4.12-next.2",
4
4
  "license": "Apache-2.0",
5
5
  "repository": {
6
6
  "type": "git",
@@ -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 = ({ panel, onClose, editor, onUpdate, labels }: InlinePanelProps): JSX.Element | null => {
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
- : labels.appendMarkdownTitle}
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
- className="text-xs px-3 py-1 rounded border border-border hover:bg-accent transition-colors"
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
- : labels.appendMarkdownConfirm}
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' : ''} />