@rimori/react-client 0.4.12-next.0 → 0.4.12-next.2

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