@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
- : 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 }), 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.1",
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 = ({ 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,9 +199,21 @@ 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
+ {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
- className="text-xs px-3 py-1 rounded border border-border hover:bg-accent transition-colors"
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
- : labels.appendMarkdownConfirm}
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' : ''} />