@rimori/react-client 0.4.12 → 0.4.13-next.1
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.
- package/dist/components/audio/Playbutton.js +1 -1
- package/dist/components/editor/MarkdownEditor.d.ts +4 -0
- package/dist/components/editor/MarkdownEditor.js +72 -14
- package/dist/providers/PluginProvider.js +23 -0
- package/package.json +6 -4
- package/src/components/audio/Playbutton.tsx +13 -5
- package/src/components/editor/MarkdownEditor.tsx +182 -92
- package/src/providers/PluginProvider.tsx +25 -0
|
@@ -133,5 +133,5 @@ export const AudioPlayer = ({ text, voice, language, hide, playListenerEvent, in
|
|
|
133
133
|
return (_jsx("div", { className: "group relative", children: _jsxs("div", { className: "flex flex-row items-end", children: [!hide && (_jsx("button", { className: "text-gray-400", onClick: togglePlayback, disabled: isLoading, children: isLoading ? (_jsx(Spinner, { size: size })) : isPlaying ? (_jsx(FaStopCircle, { size: size })) : (_jsx(FaPlayCircle, { size: size })) })), enableSpeedAdjustment && (_jsxs("div", { className: "ml-1 opacity-0 group-hover:opacity-100 transition-opacity duration-300 flex flex-row text-sm text-gray-500", children: [_jsx("span", { className: "pr-1", children: "Speed: " }), _jsx("select", { value: speed, className: "appearance-none cursor-pointer pr-0 p-0 rounded shadow leading-tight focus:outline-none focus:bg-gray-800 focus:ring bg-transparent border-0", onChange: (e) => setSpeed(parseFloat(e.target.value)), disabled: isLoading, children: AudioPlayOptions.map((s) => (_jsx("option", { value: s, children: s }, s))) })] }))] }) }));
|
|
134
134
|
};
|
|
135
135
|
const Spinner = ({ text, className, size = '30px' }) => {
|
|
136
|
-
return (_jsxs("div", { className: 'flex items-center space-x-2 ' + className, children: [_jsxs("svg", { style: { width: size, height: size }, className: "animate-spin -ml-1
|
|
136
|
+
return (_jsxs("div", { className: 'flex items-center space-x-2 pl-1 ' + className, children: [_jsxs("svg", { style: { width: size, height: size }, className: "animate-spin -ml-1 h-5 w-5 text-white", xmlns: "http://www.w3.org/2000/svg", fill: "none", viewBox: "0 0 24 24", children: [_jsx("circle", { className: "opacity-25", cx: "12", cy: "12", r: "10", stroke: "currentColor", strokeWidth: "4" }), _jsx("path", { className: "opacity-75", fill: "currentColor", d: "M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" })] }), text && _jsx("span", { className: "", children: text })] }));
|
|
137
137
|
};
|
|
@@ -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,
|
|
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
|
-
:
|
|
124
|
-
? labels.
|
|
125
|
-
: panel === '
|
|
126
|
-
? labels.
|
|
127
|
-
:
|
|
130
|
+
: panel === 'transform'
|
|
131
|
+
? labels.transformSelectionTitle
|
|
132
|
+
: labels.appendMarkdownTitle }), isTransform ? (_jsx("input", { autoFocus: true, type: "text", value: value, onChange: (e) => setValue(e.target.value), onKeyDown: handleKeyDown, placeholder: labels.transformSelectionPlaceholder, className: "w-full text-sm font-mono rounded border border-border bg-background px-2 py-1 outline-none focus:ring-1 focus:ring-ring" })) : isMarkdown ? (_jsx("textarea", { autoFocus: true, rows: 4, value: value, onChange: (e) => setValue(e.target.value), onKeyDown: (e) => e.key === 'Escape' && onClose(), placeholder: labels.appendMarkdownPlaceholder, className: "w-full text-sm font-mono rounded border border-border bg-background px-2 py-1 resize-y outline-none focus:ring-1 focus:ring-ring" })) : (_jsx("input", { autoFocus: true, type: "url", value: value, onChange: (e) => setValue(e.target.value), onKeyDown: handleKeyDown, placeholder: panel === 'link' ? labels.setLinkUrlPlaceholder : labels.addYoutubeUrlPlaceholder, className: "w-full text-sm font-mono rounded border border-border bg-background px-2 py-1 outline-none focus:ring-1 focus:ring-ring" })), _jsxs("div", { className: "flex gap-2 justify-end", children: [_jsx("button", { type: "button", onClick: onClose, disabled: isTransforming, className: "text-xs px-3 py-1 rounded border border-border hover:bg-accent transition-colors disabled:opacity-40", children: labels.cancel }), _jsxs("button", { type: "button", onClick: handleConfirm, disabled: !value.trim() || (panel === 'link' && value.trim() === 'https://') || isTransforming, className: "text-xs px-3 py-1 rounded bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-40 transition-colors flex items-center gap-1", children: [isTransforming && _jsx(LuLoader, { size: 12, className: "animate-spin" }), panel === 'link'
|
|
133
|
+
? labels.setLinkConfirm
|
|
134
|
+
: panel === 'youtube'
|
|
135
|
+
? labels.addYoutubeConfirm
|
|
136
|
+
: panel === 'transform'
|
|
137
|
+
? labels.transformSelectionConfirm
|
|
138
|
+
: labels.appendMarkdownConfirm] })] })] }));
|
|
128
139
|
};
|
|
129
|
-
const MenuBar = ({ editor, onUpdate, uploadImage, labels, onCopy, copied, isFullscreen, onToggleFullscreen, }) => {
|
|
140
|
+
const MenuBar = ({ editor, onUpdate, uploadImage, labels, onCopy, copied, isFullscreen, onToggleFullscreen, hasSelection, onTransform, isTransforming, }) => {
|
|
130
141
|
const [activePanel, setActivePanel] = useState(null);
|
|
131
142
|
const toggle = (panel) => setActivePanel((prev) => (prev === panel ? null : panel));
|
|
132
143
|
const inTable = editor.isActive('table');
|
|
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" }),
|
|
137
|
-
|
|
138
|
-
|
|
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.
|
|
3
|
+
"version": "0.4.13-next.1",
|
|
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": "
|
|
28
|
+
"@rimori/client": "2.5.24-next.0",
|
|
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": "
|
|
52
|
+
"@rimori/client": "2.5.24-next.0",
|
|
51
53
|
"@types/react": "^18.3.21",
|
|
52
54
|
"eslint-config-prettier": "^10.1.8",
|
|
53
55
|
"eslint-plugin-prettier": "^5.5.4",
|
|
@@ -58,9 +58,17 @@ export const AudioPlayer: React.FC<AudioPlayerProps> = ({
|
|
|
58
58
|
const generateAudio = async () => {
|
|
59
59
|
setIsLoading(true);
|
|
60
60
|
|
|
61
|
-
const effectiveInstructions =
|
|
62
|
-
??
|
|
63
|
-
|
|
61
|
+
const effectiveInstructions =
|
|
62
|
+
ttsInstructions ??
|
|
63
|
+
(!disableDialect && userInfo?.dialect ? `Speak with a ${userInfo.dialect} accent and pronunciation.` : undefined);
|
|
64
|
+
const blob = await ai.getVoice(
|
|
65
|
+
text,
|
|
66
|
+
voice || (language ? 'aws_default' : 'openai_alloy'),
|
|
67
|
+
1,
|
|
68
|
+
language,
|
|
69
|
+
cache,
|
|
70
|
+
effectiveInstructions,
|
|
71
|
+
);
|
|
64
72
|
setAudioUrl(URL.createObjectURL(blob));
|
|
65
73
|
setIsLoading(false);
|
|
66
74
|
};
|
|
@@ -209,10 +217,10 @@ interface SpinnerProps {
|
|
|
209
217
|
|
|
210
218
|
const Spinner = ({ text, className, size = '30px' }: SpinnerProps) => {
|
|
211
219
|
return (
|
|
212
|
-
<div className={'flex items-center space-x-2 ' + className}>
|
|
220
|
+
<div className={'flex items-center space-x-2 pl-1 ' + className}>
|
|
213
221
|
<svg
|
|
214
222
|
style={{ width: size, height: size }}
|
|
215
|
-
className="animate-spin -ml-1
|
|
223
|
+
className="animate-spin -ml-1 h-5 w-5 text-white"
|
|
216
224
|
xmlns="http://www.w3.org/2000/svg"
|
|
217
225
|
fill="none"
|
|
218
226
|
viewBox="0 0 24 24"
|
|
@@ -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 = ({
|
|
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
|
-
:
|
|
202
|
+
: panel === 'transform'
|
|
203
|
+
? labels.transformSelectionTitle
|
|
204
|
+
: labels.appendMarkdownTitle}
|
|
188
205
|
</p>
|
|
189
|
-
{
|
|
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
|
-
|
|
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
|
-
:
|
|
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
|
|
373
|
-
<
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
'
|
|
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
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
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
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
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;
|