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

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, 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 }), 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');
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(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 })] }));
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
  };
@@ -10,6 +10,7 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
10
10
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
11
11
  import { createContext, useContext, useEffect, useState } from 'react';
12
12
  import { EventBusHandler, RimoriClient, StandaloneClient } from '@rimori/client';
13
+ import posthog from 'posthog-js';
13
14
  import ContextMenu from '../components/ContextMenu';
14
15
  import { useTheme } from '../hooks/ThemeSetter';
15
16
  const PluginContext = createContext(null);
@@ -20,6 +21,22 @@ export const PluginProvider = ({ children, pluginId, settings }) => {
20
21
  const [theme, setTheme] = useState(undefined);
21
22
  const [userInfo, setUserInfo] = useState(null);
22
23
  useTheme(theme);
24
+ // Init PostHog once per plugin iframe
25
+ useEffect(() => {
26
+ if (!posthog.__loaded) {
27
+ posthog.init('phc_l4vPVZxtjlcQUDivoh5TaUL6oDe8K2lig89405cGnsv', {
28
+ cookieless_mode: 'always',
29
+ api_host: 'https://eu.i.posthog.com',
30
+ defaults: '2026-01-30',
31
+ loaded: (ph) => {
32
+ ph.register({
33
+ application: 'frontend',
34
+ pluginId: pluginId,
35
+ });
36
+ },
37
+ });
38
+ }
39
+ }, []);
23
40
  const isSidebar = applicationMode === 'sidebar';
24
41
  const isSettings = applicationMode === 'settings';
25
42
  useEffect(() => {
@@ -45,6 +62,12 @@ export const PluginProvider = ({ children, pluginId, settings }) => {
45
62
  });
46
63
  }
47
64
  }, [pluginId, standaloneClient, client]);
65
+ // Identify user in PostHog when userInfo is available
66
+ useEffect(() => {
67
+ if (userInfo === null || userInfo === void 0 ? void 0 : userInfo.user_id) {
68
+ posthog.identify(userInfo.user_id);
69
+ }
70
+ }, [userInfo === null || userInfo === void 0 ? void 0 : userInfo.user_id]);
48
71
  // Listen for RimoriInfo updates and update userInfo
49
72
  useEffect(() => {
50
73
  if (!client)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rimori/react-client",
3
- "version": "0.4.12",
3
+ "version": "0.4.13-next.0",
4
4
  "license": "Apache-2.0",
5
5
  "repository": {
6
6
  "type": "git",
@@ -19,20 +19,21 @@
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 ."
25
26
  },
26
27
  "peerDependencies": {
27
- "@rimori/client": "^2.5.21",
28
+ "@rimori/client": "^2.5.24",
28
29
  "react": "^18.1.0",
29
30
  "react-dom": "^18.1.0"
30
31
  },
31
32
  "dependencies": {
32
33
  "@tiptap/core": "^2.26.1",
33
- "@tiptap/extension-paragraph": "^2.26.1",
34
34
  "@tiptap/extension-image": "^2.26.1",
35
35
  "@tiptap/extension-link": "^2.26.1",
36
+ "@tiptap/extension-paragraph": "^2.26.1",
36
37
  "@tiptap/extension-table": "^2.26.1",
37
38
  "@tiptap/extension-table-cell": "^2.26.1",
38
39
  "@tiptap/extension-table-header": "^2.26.1",
@@ -42,12 +43,13 @@
42
43
  "@tiptap/react": "^2.26.1",
43
44
  "@tiptap/starter-kit": "^2.26.1",
44
45
  "html2canvas": "1.4.1",
46
+ "posthog-js": "^1.360.2",
45
47
  "react-icons": "5.4.0",
46
48
  "tiptap-markdown": "^0.8.10"
47
49
  },
48
50
  "devDependencies": {
49
51
  "@eslint/js": "^9.37.0",
50
- "@rimori/client": "^2.5.21",
52
+ "@rimori/client": "^2.5.24",
51
53
  "@types/react": "^18.3.21",
52
54
  "eslint-config-prettier": "^10.1.8",
53
55
  "eslint-plugin-prettier": "^5.5.4",
@@ -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';
@@ -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,9 +199,21 @@ 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
+ {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 ? (
190
217
  <textarea
191
218
  autoFocus
192
219
  rows={4}
@@ -211,21 +238,25 @@ const InlinePanel = ({ panel, onClose, editor, onUpdate, labels }: InlinePanelPr
211
238
  <button
212
239
  type="button"
213
240
  onClick={onClose}
214
- 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"
215
243
  >
216
244
  {labels.cancel}
217
245
  </button>
218
246
  <button
219
247
  type="button"
220
248
  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"
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"
223
251
  >
252
+ {isTransforming && <LuLoader size={12} className="animate-spin" />}
224
253
  {panel === 'link'
225
254
  ? labels.setLinkConfirm
226
255
  : panel === 'youtube'
227
256
  ? labels.addYoutubeConfirm
228
- : labels.appendMarkdownConfirm}
257
+ : panel === 'transform'
258
+ ? labels.transformSelectionConfirm
259
+ : labels.appendMarkdownConfirm}
229
260
  </button>
230
261
  </div>
231
262
  </div>
@@ -245,6 +276,9 @@ interface MenuBarProps {
245
276
  copied: boolean;
246
277
  isFullscreen: boolean;
247
278
  onToggleFullscreen: () => void;
279
+ hasSelection: boolean;
280
+ onTransform: (prompt: string) => Promise<void>;
281
+ isTransforming: boolean;
248
282
  }
249
283
 
250
284
  const MenuBar = ({
@@ -256,6 +290,9 @@ const MenuBar = ({
256
290
  copied,
257
291
  isFullscreen,
258
292
  onToggleFullscreen,
293
+ hasSelection,
294
+ onTransform,
295
+ isTransforming,
259
296
  }: MenuBarProps): JSX.Element => {
260
297
  const [activePanel, setActivePanel] = useState<PanelType>(null);
261
298
 
@@ -369,30 +406,26 @@ const MenuBar = ({
369
406
 
370
407
  <div className="w-px h-5 bg-border mx-0.5" />
371
408
 
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' : '')
409
+ {/* Link button — opens panel when no link active, removes link when active */}
410
+ <button
411
+ type="button"
412
+ onClick={() => {
413
+ if (isLink) {
414
+ editor.chain().focus().unsetLink().run();
415
+ } else {
416
+ toggle('link');
382
417
  }
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>
418
+ }}
419
+ title={isLink ? labels.unsetLink : labels.setLink}
420
+ className={
421
+ 'w-8 h-8 flex items-center justify-center rounded-md transition-colors duration-150 ' +
422
+ (isLink
423
+ ? 'bg-primary/10 text-primary dark:bg-primary/20 dark:text-primary-foreground hover:bg-destructive/10 hover:text-destructive'
424
+ : 'text-muted-foreground hover:bg-accent hover:text-accent-foreground')
425
+ }
426
+ >
427
+ <LuLink size={18} />
428
+ </button>
396
429
 
397
430
  {/* YouTube */}
398
431
  <button type="button" onClick={() => toggle('youtube')} className={tableBtnClass} title={labels.addYoutube}>
@@ -422,65 +455,72 @@ const MenuBar = ({
422
455
  >
423
456
  <TbTable size={18} />
424
457
  </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>
458
+ {inTable && (
459
+ <>
460
+ <button
461
+ type="button"
462
+ onClick={() => editor.chain().focus().addColumnAfter().run()}
463
+ className={tableBtnClass}
464
+ title={labels.addColumnAfter}
465
+ >
466
+ <TbColumnInsertRight size={18} />
467
+ </button>
468
+ <button
469
+ type="button"
470
+ onClick={() => editor.chain().focus().addRowAfter().run()}
471
+ className={tableBtnClass}
472
+ title={labels.addRowAfter}
473
+ >
474
+ <TbRowInsertBottom size={18} />
475
+ </button>
476
+ <button
477
+ type="button"
478
+ onClick={() => editor.chain().focus().deleteColumn().run()}
479
+ className={tableBtnClass}
480
+ title={labels.deleteColumn}
481
+ >
482
+ <TbColumnRemove size={18} />
483
+ </button>
484
+ <button
485
+ type="button"
486
+ onClick={() => editor.chain().focus().deleteRow().run()}
487
+ className={tableBtnClass}
488
+ title={labels.deleteRow}
489
+ >
490
+ <TbRowRemove size={18} />
491
+ </button>
492
+ <button
493
+ type="button"
494
+ onClick={() => editor.chain().focus().mergeOrSplit().run()}
495
+ className={tableBtnClass}
496
+ title={labels.mergeOrSplit}
497
+ >
498
+ <TbArrowMergeBoth size={18} />
499
+ </button>
500
+ </>
501
+ )}
482
502
 
483
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
+ )}
515
+ {/* Append raw markdown */}
516
+ <button
517
+ type="button"
518
+ onClick={() => toggle('markdown')}
519
+ className={tableBtnClass}
520
+ title={labels.appendMarkdown}
521
+ >
522
+ <LuClipboardPaste size={18} style={{ transform: 'scaleX(-1)' }} />
523
+ </button>
484
524
  <button type="button" onClick={onCopy} title="Copy as Markdown" className={tableBtnClass}>
485
525
  {copied ? <LuCheck size={16} className="text-green-500" /> : <LuCopy size={16} />}
486
526
  </button>
@@ -497,10 +537,15 @@ const MenuBar = ({
497
537
 
498
538
  <InlinePanel
499
539
  panel={activePanel}
500
- onClose={() => setActivePanel(null)}
540
+ onClose={() => !isTransforming && setActivePanel(null)}
501
541
  editor={editor}
502
542
  onUpdate={onUpdate}
503
543
  labels={labels}
544
+ onTransform={async (prompt) => {
545
+ await onTransform(prompt);
546
+ setActivePanel(null);
547
+ }}
548
+ isTransforming={isTransforming}
504
549
  />
505
550
  </>
506
551
  );
@@ -543,6 +588,10 @@ export interface EditorLabels {
543
588
  appendMarkdownTitle?: string;
544
589
  appendMarkdownPlaceholder?: string;
545
590
  appendMarkdownConfirm?: string;
591
+ transformSelection?: string;
592
+ transformSelectionTitle?: string;
593
+ transformSelectionPlaceholder?: string;
594
+ transformSelectionConfirm?: string;
546
595
  cancel?: string;
547
596
  }
548
597
 
@@ -579,6 +628,10 @@ const DEFAULT_LABELS: Required<EditorLabels> = {
579
628
  appendMarkdownTitle: 'Append Markdown',
580
629
  appendMarkdownPlaceholder: 'Paste markdown here…',
581
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',
582
635
  cancel: 'Cancel',
583
636
  };
584
637
 
@@ -605,11 +658,13 @@ export const MarkdownEditor = ({
605
658
  labels,
606
659
  onContentClick,
607
660
  }: MarkdownEditorProps): JSX.Element => {
608
- const { storage } = useRimori();
661
+ const { storage, ai } = useRimori();
609
662
  const mergedLabels: Required<EditorLabels> = { ...DEFAULT_LABELS, ...labels };
610
663
  const lastEmittedRef = useRef(content ?? '');
611
664
  const [isFullscreen, setIsFullscreen] = useState(false);
612
665
  const [copied, setCopied] = useState(false);
666
+ const [hasSelection, setHasSelection] = useState(false);
667
+ const [isTransforming, setIsTransforming] = useState(false);
613
668
 
614
669
  const stableUpload = useCallback(
615
670
  async (pngBlob: Blob): Promise<string | null> => {
@@ -649,11 +704,43 @@ export const MarkdownEditor = ({
649
704
  lastEmittedRef.current = markdown;
650
705
  onUpdate?.(markdown);
651
706
  },
707
+ onSelectionUpdate: ({ editor: ed }) => {
708
+ const { from, to } = ed.state.selection;
709
+ setHasSelection(from !== to);
710
+ },
652
711
  });
653
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
+
654
741
  const handleCopy = useCallback(() => {
655
742
  if (!editor) return;
656
- navigator.clipboard.writeText(getMarkdown(editor)).then(() => {
743
+ void navigator.clipboard.writeText(getMarkdown(editor)).then(() => {
657
744
  setCopied(true);
658
745
  setTimeout(() => setCopied(false), 2000);
659
746
  });
@@ -694,6 +781,9 @@ export const MarkdownEditor = ({
694
781
  copied={copied}
695
782
  isFullscreen={isFullscreen}
696
783
  onToggleFullscreen={() => setIsFullscreen((v) => !v)}
784
+ hasSelection={hasSelection}
785
+ onTransform={handleTransform}
786
+ isTransforming={isTransforming}
697
787
  />
698
788
  )}
699
789
  <EditorContent editor={editor} className={isFullscreen ? 'flex-1 overflow-y-auto' : ''} />
@@ -1,6 +1,7 @@
1
1
  import React, { createContext, useContext, ReactNode, useEffect, useState } from 'react';
2
2
  import { EventBusHandler, RimoriClient, StandaloneClient } from '@rimori/client';
3
3
  import type { UserInfo } from '@rimori/client';
4
+ import posthog from 'posthog-js';
4
5
  import ContextMenu from '../components/ContextMenu';
5
6
  import { useTheme } from '../hooks/ThemeSetter';
6
7
  import { Theme } from '@rimori/client';
@@ -29,6 +30,23 @@ export const PluginProvider: React.FC<PluginProviderProps> = ({ children, plugin
29
30
 
30
31
  useTheme(theme);
31
32
 
33
+ // Init PostHog once per plugin iframe
34
+ useEffect(() => {
35
+ if (!posthog.__loaded) {
36
+ posthog.init('phc_l4vPVZxtjlcQUDivoh5TaUL6oDe8K2lig89405cGnsv', {
37
+ cookieless_mode: 'always',
38
+ api_host: 'https://eu.i.posthog.com',
39
+ defaults: '2026-01-30',
40
+ loaded: (ph) => {
41
+ ph.register({
42
+ application: 'frontend',
43
+ pluginId: pluginId,
44
+ });
45
+ },
46
+ });
47
+ }
48
+ }, []);
49
+
32
50
  const isSidebar = applicationMode === 'sidebar';
33
51
  const isSettings = applicationMode === 'settings';
34
52
 
@@ -60,6 +78,13 @@ export const PluginProvider: React.FC<PluginProviderProps> = ({ children, plugin
60
78
  }
61
79
  }, [pluginId, standaloneClient, client]);
62
80
 
81
+ // Identify user in PostHog when userInfo is available
82
+ useEffect(() => {
83
+ if (userInfo?.user_id) {
84
+ posthog.identify(userInfo.user_id);
85
+ }
86
+ }, [userInfo?.user_id]);
87
+
63
88
  // Listen for RimoriInfo updates and update userInfo
64
89
  useEffect(() => {
65
90
  if (!client) return;