@oix1987/yjd 2.1.2 → 2.2.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.
- package/README.md +63 -4
- package/core.js +1 -0
- package/dist/core.esm.js +1 -1
- package/dist/core.esm.js.map +1 -1
- package/dist/rich-editor.esm.js +1 -1
- package/dist/rich-editor.esm.js.map +1 -1
- package/dist/rich-editor.min.js +1 -1
- package/dist/rich-editor.min.js.map +1 -1
- package/index.d.ts +89 -0
- package/index.js +3 -0
- package/lib/core/editor.js +95 -1
- package/lib/modules/ai.js +494 -0
- package/lib/modules/toolbar.js +14 -2
- package/lib/styles.css +86 -0
- package/lib/styles.css.js +1 -1
- package/lib/styles.min.css +1 -1
- package/lib/ui/icons.js +2 -0
- package/package.json +2 -2
|
@@ -0,0 +1,494 @@
|
|
|
1
|
+
import Module from '../core/module.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* AI module — turns yjd into a "write-with-AI" surface without bundling any
|
|
5
|
+
* model. Like @mention's `source`, the app supplies a `complete` hook that
|
|
6
|
+
* calls whatever LLM it likes; the module is INERT until one is given.
|
|
7
|
+
*
|
|
8
|
+
* new Editor(el, {
|
|
9
|
+
* ai: {
|
|
10
|
+
* // REQUIRED. Resolve to the generated text. Stream by calling onToken
|
|
11
|
+
* // with each chunk (the returned/!resolved string is the source of
|
|
12
|
+
* // truth; if you only stream, return undefined and chunks are joined).
|
|
13
|
+
* complete: async ({ action, prompt, text, html, signal }, onToken) => {
|
|
14
|
+
* const res = await fetch('/api/ai', {
|
|
15
|
+
* method: 'POST', signal,
|
|
16
|
+
* body: JSON.stringify({ action, prompt, text })
|
|
17
|
+
* });
|
|
18
|
+
* return (await res.json()).text;
|
|
19
|
+
* },
|
|
20
|
+
* // optional — replace/extend the selection-toolbar actions
|
|
21
|
+
* actions: [{ id, label, prompt }],
|
|
22
|
+
* // optional — inline ghost-text autocomplete (Tab to accept)
|
|
23
|
+
* autocomplete: true | { debounce: 400, minChars: 3, maxContext: 600 },
|
|
24
|
+
* }
|
|
25
|
+
* })
|
|
26
|
+
*
|
|
27
|
+
* Events: ai:start {action}, ai:done {action, result}, ai:accept {result},
|
|
28
|
+
* ai:discard, ai:error {error}.
|
|
29
|
+
*
|
|
30
|
+
* Nothing the module renders ever lives in the editable DOM, so getContent()/
|
|
31
|
+
* getJSON()/onChange stay clean: the selection toolbar is portaled to <body>
|
|
32
|
+
* and the ghost-text hint is a positioned overlay, not editable content.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
// Built-in selection actions. Each `prompt` is handed to complete() verbatim as
|
|
36
|
+
// the instruction; the app's hook decides how to combine it with `text`.
|
|
37
|
+
const DEFAULT_ACTIONS = [
|
|
38
|
+
{ id: 'improve', label: 'Improve writing', prompt: 'Improve the writing, grammar and clarity of the text. Return only the rewritten text, no preamble.' },
|
|
39
|
+
{ id: 'fix', label: 'Fix spelling & grammar', prompt: 'Fix spelling and grammar. Return only the corrected text.' },
|
|
40
|
+
{ id: 'shorten', label: 'Make shorter', prompt: 'Make the text more concise while keeping its meaning. Return only the text.' },
|
|
41
|
+
{ id: 'lengthen', label: 'Make longer', prompt: 'Expand the text with more detail while keeping its tone. Return only the text.' },
|
|
42
|
+
{ id: 'simplify', label: 'Simplify', prompt: 'Rewrite the text in simpler, clearer language. Return only the text.' },
|
|
43
|
+
{ id: 'summarize', label: 'Summarize', prompt: 'Summarize the text in one or two sentences. Return only the summary.' },
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
// --rte-* theme vars copied onto the portaled menus so a themed editor themes
|
|
47
|
+
// its AI surfaces too (mirrors Mention.THEME_VARS).
|
|
48
|
+
const THEME_VARS = ['--rte-accent', '--rte-accent-ink', '--rte-accent-weak', '--rte-ink', '--rte-muted', '--rte-border', '--rte-bg', '--rte-radius-md', '--rte-shadow'];
|
|
49
|
+
|
|
50
|
+
export default class Ai extends Module {
|
|
51
|
+
constructor(editor, options = {}) {
|
|
52
|
+
super(editor, options);
|
|
53
|
+
this.cfg = editor.options.ai || options || {};
|
|
54
|
+
this.actions = Array.isArray(this.cfg.actions) && this.cfg.actions.length
|
|
55
|
+
? this.cfg.actions
|
|
56
|
+
: DEFAULT_ACTIONS;
|
|
57
|
+
this.savedRange = null; // selection captured when the toolbar opens
|
|
58
|
+
this.controller = null; // AbortController for the in-flight request
|
|
59
|
+
this.ghost = null; // pending ghost-text suggestion string
|
|
60
|
+
this.reqSeq = 0; // monotonic id so a stale completion can't win
|
|
61
|
+
this.auto = this._parseAutocfg(); // static after construction
|
|
62
|
+
|
|
63
|
+
if (!this.enabled) return;
|
|
64
|
+
// Public, documented handle: editor.ai.run(...) / editor.ai.autocomplete().
|
|
65
|
+
this.editor.ai = this;
|
|
66
|
+
this._build();
|
|
67
|
+
this._bind();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Inert until the app provides a model call. */
|
|
71
|
+
get enabled() { return typeof this.cfg.complete === 'function'; }
|
|
72
|
+
|
|
73
|
+
/* --------------------------------------------------------------------- */
|
|
74
|
+
/* DOM */
|
|
75
|
+
/* --------------------------------------------------------------------- */
|
|
76
|
+
|
|
77
|
+
_build() {
|
|
78
|
+
// Floating selection toolbar (actions + free-form ask).
|
|
79
|
+
const bar = document.createElement('div');
|
|
80
|
+
bar.className = 'yjd-ai-bar';
|
|
81
|
+
bar.setAttribute('role', 'toolbar');
|
|
82
|
+
bar.style.display = 'none';
|
|
83
|
+
bar.innerHTML =
|
|
84
|
+
'<div class="yjd-ai-actions"></div>' +
|
|
85
|
+
'<form class="yjd-ai-ask"><input type="text" class="yjd-ai-input" ' +
|
|
86
|
+
'placeholder="Ask AI to edit or write…" aria-label="Ask AI"></form>' +
|
|
87
|
+
'<div class="yjd-ai-panel" hidden>' +
|
|
88
|
+
'<div class="yjd-ai-result" aria-live="polite"></div>' +
|
|
89
|
+
'<div class="yjd-ai-foot">' +
|
|
90
|
+
'<button type="button" class="yjd-ai-accept" data-act="accept">Accept</button>' +
|
|
91
|
+
'<button type="button" class="yjd-ai-retry" data-act="retry">Retry</button>' +
|
|
92
|
+
'<button type="button" class="yjd-ai-discard" data-act="discard">Discard</button>' +
|
|
93
|
+
'</div>' +
|
|
94
|
+
'</div>';
|
|
95
|
+
this.bar = bar;
|
|
96
|
+
this.actionsEl = bar.querySelector('.yjd-ai-actions');
|
|
97
|
+
this.panel = bar.querySelector('.yjd-ai-panel');
|
|
98
|
+
this.resultEl = bar.querySelector('.yjd-ai-result');
|
|
99
|
+
this.input = bar.querySelector('.yjd-ai-input');
|
|
100
|
+
|
|
101
|
+
this.actions.forEach((a) => {
|
|
102
|
+
const b = document.createElement('button');
|
|
103
|
+
b.type = 'button';
|
|
104
|
+
b.className = 'yjd-ai-act';
|
|
105
|
+
b.dataset.id = a.id;
|
|
106
|
+
b.textContent = a.label || a.id;
|
|
107
|
+
// pointerdown (not click) + preventDefault keeps the editor selection.
|
|
108
|
+
b.addEventListener('pointerdown', (e) => { e.preventDefault(); this.run(a); });
|
|
109
|
+
this.actionsEl.appendChild(b);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
bar.querySelector('.yjd-ai-ask').addEventListener('submit', (e) => {
|
|
113
|
+
e.preventDefault();
|
|
114
|
+
const q = this.input.value.trim();
|
|
115
|
+
if (q) this.run({ id: 'ask', label: 'Ask AI', prompt: q });
|
|
116
|
+
});
|
|
117
|
+
// Keep selection while focusing the input.
|
|
118
|
+
this.input.addEventListener('pointerdown', (e) => e.stopPropagation());
|
|
119
|
+
this.panel.querySelector('.yjd-ai-foot').addEventListener('pointerdown', (e) => e.preventDefault());
|
|
120
|
+
this.panel.querySelector('[data-act="accept"]').addEventListener('click', () => this._accept());
|
|
121
|
+
this.panel.querySelector('[data-act="retry"]').addEventListener('click', () => this._retry());
|
|
122
|
+
this.panel.querySelector('[data-act="discard"]').addEventListener('click', () => this.closeBar());
|
|
123
|
+
document.body.appendChild(bar);
|
|
124
|
+
|
|
125
|
+
if (this.auto) {
|
|
126
|
+
const g = document.createElement('span');
|
|
127
|
+
g.className = 'yjd-ai-ghost';
|
|
128
|
+
g.setAttribute('aria-hidden', 'true');
|
|
129
|
+
g.style.display = 'none';
|
|
130
|
+
this.ghostEl = g;
|
|
131
|
+
(this.editor.wrapper || document.body).appendChild(g);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
_bind() {
|
|
136
|
+
// Open the toolbar on a settled non-empty selection inside the editor.
|
|
137
|
+
this._onSelect = () => {
|
|
138
|
+
if (this._busy) return;
|
|
139
|
+
clearTimeout(this._selT);
|
|
140
|
+
this._selT = setTimeout(() => this._maybeOpenBar(), 80);
|
|
141
|
+
};
|
|
142
|
+
document.addEventListener('selectionchange', this._onSelect);
|
|
143
|
+
|
|
144
|
+
this._onDocPointer = (e) => {
|
|
145
|
+
if (this.barOpen && !this.bar.contains(e.target) && !this.editor.editor.contains(e.target)) this.closeBar();
|
|
146
|
+
};
|
|
147
|
+
document.addEventListener('pointerdown', this._onDocPointer, true);
|
|
148
|
+
|
|
149
|
+
// Ghost-text autocomplete (opt-in).
|
|
150
|
+
if (this.auto) {
|
|
151
|
+
this._onInput = () => this._scheduleGhost();
|
|
152
|
+
this.editor.editor.addEventListener('input', this._onInput);
|
|
153
|
+
this._onGhostKey = (e) => this._ghostKeydown(e);
|
|
154
|
+
this.editor.editor.addEventListener('keydown', this._onGhostKey, true);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
_parseAutocfg() {
|
|
159
|
+
const a = this.cfg.autocomplete;
|
|
160
|
+
if (!a) return null;
|
|
161
|
+
const d = a === true ? {} : a;
|
|
162
|
+
return { debounce: d.debounce ?? 400, minChars: d.minChars ?? 3, maxContext: d.maxContext ?? 600 };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
_applyTheme(el) {
|
|
166
|
+
const root = this.editor.wrapper || this.editor.root;
|
|
167
|
+
if (!root) return;
|
|
168
|
+
const cs = getComputedStyle(root);
|
|
169
|
+
THEME_VARS.forEach((v) => {
|
|
170
|
+
const val = cs.getPropertyValue(v);
|
|
171
|
+
if (val) el.style.setProperty(v, val.trim());
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/* --------------------------------------------------------------------- */
|
|
176
|
+
/* Selection toolbar */
|
|
177
|
+
/* --------------------------------------------------------------------- */
|
|
178
|
+
|
|
179
|
+
_selectionInEditor() {
|
|
180
|
+
const sel = window.getSelection();
|
|
181
|
+
if (!sel || !sel.rangeCount) return null;
|
|
182
|
+
const range = sel.getRangeAt(0);
|
|
183
|
+
if (range.collapsed) return null;
|
|
184
|
+
if (!this.editor.editor.contains(range.commonAncestorContainer)) return null;
|
|
185
|
+
return range;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
_maybeOpenBar() {
|
|
189
|
+
if (this.barOpen && this._busy) return;
|
|
190
|
+
// A toolbar-opened bar is pinned — ignore selection changes entirely.
|
|
191
|
+
if (this._pinned) return;
|
|
192
|
+
// Don't react to the selection collapsing because focus moved INTO the bar
|
|
193
|
+
// (e.g. the user clicked the "Ask AI" input) — that must not close it.
|
|
194
|
+
if (this.bar.contains(document.activeElement)) return;
|
|
195
|
+
const range = this._selectionInEditor();
|
|
196
|
+
if (!range || !range.toString().trim()) {
|
|
197
|
+
if (this.barOpen && !this._busy) this.closeBar();
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
this.savedRange = range.cloneRange();
|
|
201
|
+
this._openBar(range);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Open the bar from the toolbar button. With a selection it behaves like the
|
|
206
|
+
* auto-popup (run actions on the selection); with none it opens at the caret
|
|
207
|
+
* and focuses the "Ask AI" field for free-form generation inserted there.
|
|
208
|
+
*/
|
|
209
|
+
openFromToolbar() {
|
|
210
|
+
if (!this.enabled) return;
|
|
211
|
+
// Pinned: opened deliberately, so selection changes must NOT auto-close it
|
|
212
|
+
// (only an outside click / Discard / Accept should). Cleared on close.
|
|
213
|
+
this._pinned = true;
|
|
214
|
+
const range = this._selectionInEditor();
|
|
215
|
+
if (range && range.toString().trim()) {
|
|
216
|
+
this.savedRange = range.cloneRange();
|
|
217
|
+
this._openBar(range);
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
// No selection → anchor at the caret (if inside the editor) and ask.
|
|
221
|
+
const sel = window.getSelection();
|
|
222
|
+
const caret = (sel && sel.rangeCount && this.editor.editor.contains(sel.getRangeAt(0).commonAncestorContainer))
|
|
223
|
+
? sel.getRangeAt(0).cloneRange() : null;
|
|
224
|
+
this.savedRange = caret; // collapsed caret → accept inserts here
|
|
225
|
+
this.barOpen = true;
|
|
226
|
+
this._resetPanel();
|
|
227
|
+
this.bar.style.display = 'block';
|
|
228
|
+
this._applyTheme(this.bar);
|
|
229
|
+
this._positionBar(caret);
|
|
230
|
+
// Defer so the click's own focus settling doesn't steal it back.
|
|
231
|
+
setTimeout(() => this.input.focus(), 0); // jump straight to "Ask AI…"
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Show the bar (action row) anchored to a range and position it. */
|
|
235
|
+
_openBar(range) {
|
|
236
|
+
this.barOpen = true;
|
|
237
|
+
this._resetPanel();
|
|
238
|
+
this.bar.style.display = 'block';
|
|
239
|
+
this._applyTheme(this.bar);
|
|
240
|
+
this._positionBar(range);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Position the bar under (or above, if no room) the anchor range, clamped to
|
|
245
|
+
* the viewport. Falls back to the editor's box when no range is available
|
|
246
|
+
* (e.g. a programmatic run() with no prior selection).
|
|
247
|
+
*/
|
|
248
|
+
_positionBar(range) {
|
|
249
|
+
const rect = (range && range.getClientRects().length)
|
|
250
|
+
? range.getBoundingClientRect()
|
|
251
|
+
: this.editor.editor.getBoundingClientRect();
|
|
252
|
+
const bw = this.bar.offsetWidth;
|
|
253
|
+
const bh = this.bar.offsetHeight;
|
|
254
|
+
const x = Math.max(8 + window.scrollX, Math.min(rect.left + window.scrollX, window.scrollX + window.innerWidth - bw - 8));
|
|
255
|
+
let y = rect.bottom + window.scrollY + 8;
|
|
256
|
+
if (rect.bottom + bh + 16 > window.innerHeight) y = rect.top + window.scrollY - bh - 8;
|
|
257
|
+
this.bar.style.left = `${Math.round(x)}px`;
|
|
258
|
+
this.bar.style.top = `${Math.round(Math.max(window.scrollY + 8, y))}px`;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
closeBar() {
|
|
262
|
+
if (!this.barOpen) return;
|
|
263
|
+
this._abort();
|
|
264
|
+
this.barOpen = false;
|
|
265
|
+
this._busy = false;
|
|
266
|
+
this._pinned = false;
|
|
267
|
+
this.bar.style.display = 'none';
|
|
268
|
+
const wasGenerating = this._panelShown;
|
|
269
|
+
this._resetPanel();
|
|
270
|
+
this.savedRange = null;
|
|
271
|
+
// Only signal a discard when there was actually a result/in-flight request
|
|
272
|
+
// to discard — not when the user merely deselected.
|
|
273
|
+
if (wasGenerating) this.editor.emit('ai:discard', {});
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
_resetPanel() {
|
|
277
|
+
this.panel.hidden = true;
|
|
278
|
+
this.resultEl.textContent = '';
|
|
279
|
+
this.actionsEl.style.display = '';
|
|
280
|
+
this.bar.querySelector('.yjd-ai-ask').style.display = '';
|
|
281
|
+
this.input.value = '';
|
|
282
|
+
this.lastResult = '';
|
|
283
|
+
this._panelShown = false;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/* --------------------------------------------------------------------- */
|
|
287
|
+
/* Running a request */
|
|
288
|
+
/* --------------------------------------------------------------------- */
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Run an AI action against the current selection (or `opts.text`). Accepts a
|
|
292
|
+
* built-in/custom action object or a free-form prompt string. Returns the
|
|
293
|
+
* generated text. Public: editor.ai.run('Translate to French').
|
|
294
|
+
*/
|
|
295
|
+
async run(action, opts = {}) {
|
|
296
|
+
if (!this.enabled) return '';
|
|
297
|
+
const act = typeof action === 'string' ? { id: 'ask', label: 'Ask AI', prompt: action } : action;
|
|
298
|
+
// Capture the selection if the bar wasn't opened from one (programmatic call).
|
|
299
|
+
if (!this.savedRange) {
|
|
300
|
+
const r = this._selectionInEditor();
|
|
301
|
+
if (r) this.savedRange = r.cloneRange();
|
|
302
|
+
}
|
|
303
|
+
const text = opts.text != null ? opts.text : (this.savedRange ? this.savedRange.toString() : '');
|
|
304
|
+
this.lastAction = act;
|
|
305
|
+
|
|
306
|
+
// Make sure the bar is visible (a programmatic run() has no open bar yet).
|
|
307
|
+
if (!this.barOpen) { this.barOpen = true; this._applyTheme(this.bar); }
|
|
308
|
+
this.bar.style.display = 'block';
|
|
309
|
+
|
|
310
|
+
this._abort();
|
|
311
|
+
this.controller = new AbortController();
|
|
312
|
+
const myReq = ++this.reqSeq; // token: only the latest request may win
|
|
313
|
+
this._busy = true;
|
|
314
|
+
this._showPanel('');
|
|
315
|
+
this.editor.emit('ai:start', { action: act.id });
|
|
316
|
+
|
|
317
|
+
let acc = '';
|
|
318
|
+
const onToken = (chunk) => {
|
|
319
|
+
if (typeof chunk !== 'string' || myReq !== this.reqSeq) return;
|
|
320
|
+
acc += chunk;
|
|
321
|
+
this._showPanel(acc);
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
try {
|
|
325
|
+
const ret = await this.cfg.complete(
|
|
326
|
+
{ action: act.id, prompt: act.prompt || act.id, text, html: opts.html || '', signal: this.controller.signal },
|
|
327
|
+
onToken
|
|
328
|
+
);
|
|
329
|
+
// A newer request (Retry / another action) superseded this one — discard
|
|
330
|
+
// its result even if the app ignored the abort signal.
|
|
331
|
+
if (myReq !== this.reqSeq) return '';
|
|
332
|
+
const result = (typeof ret === 'string' && ret.length ? ret : acc).trim();
|
|
333
|
+
this._busy = false;
|
|
334
|
+
this.lastResult = result;
|
|
335
|
+
this._showPanel(result);
|
|
336
|
+
this.editor.emit('ai:done', { action: act.id, result });
|
|
337
|
+
return result;
|
|
338
|
+
} catch (err) {
|
|
339
|
+
if (myReq !== this.reqSeq) return '';
|
|
340
|
+
this._busy = false;
|
|
341
|
+
if (err && err.name === 'AbortError') return '';
|
|
342
|
+
this._showError(err);
|
|
343
|
+
this.editor.emit('ai:error', { error: err });
|
|
344
|
+
return '';
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
_showPanel(text) {
|
|
349
|
+
this.actionsEl.style.display = 'none';
|
|
350
|
+
this.bar.querySelector('.yjd-ai-ask').style.display = 'none';
|
|
351
|
+
this.panel.hidden = false;
|
|
352
|
+
this.resultEl.classList.remove('is-error');
|
|
353
|
+
this.resultEl.textContent = text || '…';
|
|
354
|
+
// Reposition only once when the panel first appears (it's taller than the
|
|
355
|
+
// action row) — not on every streamed token, which would thrash layout.
|
|
356
|
+
if (!this._panelShown) {
|
|
357
|
+
this._panelShown = true;
|
|
358
|
+
this._positionBar(this.savedRange);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
_showError(err) {
|
|
363
|
+
this._busy = false;
|
|
364
|
+
this._panelShown = true;
|
|
365
|
+
this.panel.hidden = false;
|
|
366
|
+
this.resultEl.classList.add('is-error');
|
|
367
|
+
this.resultEl.textContent = (err && err.message) ? err.message : 'Something went wrong.';
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
_accept() {
|
|
371
|
+
const result = this.lastResult;
|
|
372
|
+
if (!result) return;
|
|
373
|
+
// Focus FIRST, then restore the original selection — focusing an editable
|
|
374
|
+
// in Chrome can clobber a range that was set while it was unfocused.
|
|
375
|
+
this.editor.focus();
|
|
376
|
+
if (this.savedRange) {
|
|
377
|
+
try {
|
|
378
|
+
const sel = window.getSelection();
|
|
379
|
+
sel.removeAllRanges();
|
|
380
|
+
sel.addRange(this.savedRange);
|
|
381
|
+
} catch (e) { /* range detached — fall back to the live caret */ }
|
|
382
|
+
}
|
|
383
|
+
this.editor.replaceSelection(result, { asText: true });
|
|
384
|
+
this.editor.emit('ai:accept', { result });
|
|
385
|
+
this.barOpen = false;
|
|
386
|
+
this._pinned = false;
|
|
387
|
+
this.bar.style.display = 'none';
|
|
388
|
+
this._resetPanel();
|
|
389
|
+
this.savedRange = null;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
_retry() {
|
|
393
|
+
if (this.lastAction) this.run(this.lastAction);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
_abort() {
|
|
397
|
+
if (this.controller) { try { this.controller.abort(); } catch (e) { /* noop */ } this.controller = null; }
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/* --------------------------------------------------------------------- */
|
|
401
|
+
/* Ghost-text autocomplete */
|
|
402
|
+
/* --------------------------------------------------------------------- */
|
|
403
|
+
|
|
404
|
+
_scheduleGhost() {
|
|
405
|
+
const cfg = this.auto;
|
|
406
|
+
if (!cfg || this._busy) return;
|
|
407
|
+
this._hideGhost();
|
|
408
|
+
this._abortGhost(); // cancel any in-flight request so it can't render stale
|
|
409
|
+
clearTimeout(this._ghostT);
|
|
410
|
+
this._ghostT = setTimeout(() => this._requestGhost(cfg), cfg.debounce);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
async _requestGhost(cfg) {
|
|
414
|
+
const sel = window.getSelection();
|
|
415
|
+
if (!sel || !sel.rangeCount || !sel.isCollapsed) return;
|
|
416
|
+
const range = sel.getRangeAt(0);
|
|
417
|
+
if (!this.editor.editor.contains(range.commonAncestorContainer)) return;
|
|
418
|
+
// Only suggest at the end of a text run (no text immediately to the right).
|
|
419
|
+
const node = range.startContainer;
|
|
420
|
+
if (node.nodeType === Node.TEXT_NODE && range.startOffset < node.textContent.length) return;
|
|
421
|
+
const ctxText = this.editor.getText().slice(-cfg.maxContext);
|
|
422
|
+
if (ctxText.trim().length < cfg.minChars) return;
|
|
423
|
+
|
|
424
|
+
this._abortGhost();
|
|
425
|
+
this._ghostCtrl = new AbortController();
|
|
426
|
+
const signal = this._ghostCtrl.signal;
|
|
427
|
+
try {
|
|
428
|
+
const ret = await this.cfg.complete({ action: 'autocomplete', prompt: '', text: ctxText, html: '', signal }, () => {});
|
|
429
|
+
const suggestion = typeof ret === 'string' ? ret : '';
|
|
430
|
+
if (!suggestion || signal.aborted) return;
|
|
431
|
+
// Stale guard: caret must still be collapsed where we asked.
|
|
432
|
+
const s2 = window.getSelection();
|
|
433
|
+
if (!s2 || !s2.isCollapsed) return;
|
|
434
|
+
this._showGhost(suggestion, s2.getRangeAt(0));
|
|
435
|
+
} catch (e) { /* ignore */ }
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
_showGhost(text, range) {
|
|
439
|
+
if (!this.ghostEl) return;
|
|
440
|
+
this.ghost = text;
|
|
441
|
+
this.ghostEl.textContent = text;
|
|
442
|
+
const rect = range.getBoundingClientRect();
|
|
443
|
+
const host = (this.editor.wrapper || document.body).getBoundingClientRect();
|
|
444
|
+
if (!rect || (!rect.width && !rect.height && !rect.left)) return;
|
|
445
|
+
this.ghostEl.style.display = 'inline';
|
|
446
|
+
this.ghostEl.style.left = `${Math.round(rect.left - host.left + (this.editor.wrapper ? this.editor.wrapper.scrollLeft : 0))}px`;
|
|
447
|
+
this.ghostEl.style.top = `${Math.round(rect.top - host.top + (this.editor.wrapper ? this.editor.wrapper.scrollTop : 0))}px`;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
_hideGhost() {
|
|
451
|
+
this.ghost = null;
|
|
452
|
+
if (this.ghostEl) this.ghostEl.style.display = 'none';
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
_abortGhost() {
|
|
456
|
+
if (this._ghostCtrl) { try { this._ghostCtrl.abort(); } catch (e) { /* noop */ } this._ghostCtrl = null; }
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/** Manually trigger a completion at the caret (public). */
|
|
460
|
+
autocomplete() { if (this.auto) this._requestGhost(this.auto); }
|
|
461
|
+
|
|
462
|
+
_ghostKeydown(e) {
|
|
463
|
+
if (!this.ghost) return;
|
|
464
|
+
if (e.key === 'Tab') {
|
|
465
|
+
e.preventDefault();
|
|
466
|
+
e.stopPropagation();
|
|
467
|
+
const text = this.ghost;
|
|
468
|
+
this._hideGhost();
|
|
469
|
+
this.editor.insertText(text);
|
|
470
|
+
} else if (e.key === 'Escape') {
|
|
471
|
+
this._hideGhost();
|
|
472
|
+
} else if (['Shift', 'Control', 'Alt', 'Meta', 'CapsLock'].includes(e.key)) {
|
|
473
|
+
// Bare modifier presses don't change the text — keep the suggestion.
|
|
474
|
+
} else {
|
|
475
|
+
// Any text-changing / navigation key invalidates the suggestion.
|
|
476
|
+
this._hideGhost();
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
destroy() {
|
|
481
|
+
this._abort();
|
|
482
|
+
this._abortGhost();
|
|
483
|
+
clearTimeout(this._selT);
|
|
484
|
+
clearTimeout(this._ghostT);
|
|
485
|
+
if (this._onSelect) document.removeEventListener('selectionchange', this._onSelect);
|
|
486
|
+
if (this._onDocPointer) document.removeEventListener('pointerdown', this._onDocPointer, true);
|
|
487
|
+
if (this._onInput) this.editor.editor.removeEventListener('input', this._onInput);
|
|
488
|
+
if (this._onGhostKey) this.editor.editor.removeEventListener('keydown', this._onGhostKey, true);
|
|
489
|
+
if (this.bar && this.bar.parentNode) this.bar.parentNode.removeChild(this.bar);
|
|
490
|
+
if (this.ghostEl && this.ghostEl.parentNode) this.ghostEl.parentNode.removeChild(this.ghostEl);
|
|
491
|
+
if (this.editor.ai === this) delete this.editor.ai;
|
|
492
|
+
super.destroy();
|
|
493
|
+
}
|
|
494
|
+
}
|
package/lib/modules/toolbar.js
CHANGED
|
@@ -87,8 +87,19 @@ class Toolbar extends Module {
|
|
|
87
87
|
// Use full default configuration
|
|
88
88
|
this.options = { ...Toolbar.DEFAULTS, ...options };
|
|
89
89
|
}
|
|
90
|
-
|
|
91
|
-
|
|
90
|
+
|
|
91
|
+
// Surface an AI button at the start of row 1 whenever a model is configured
|
|
92
|
+
// (the AI module is otherwise inert). Dedupe so a hand-placed 'ai' in a
|
|
93
|
+
// custom toolbar config isn't duplicated.
|
|
94
|
+
const aiCfg = this.editor.options.ai;
|
|
95
|
+
if (aiCfg && typeof aiCfg.complete === 'function') {
|
|
96
|
+
const present = [this.options.toolbar1, this.options.toolbar2]
|
|
97
|
+
.some(rows => (rows || []).some(g => g.items && g.items.includes('ai')));
|
|
98
|
+
if (!present) {
|
|
99
|
+
this.options.toolbar1 = [{ group: 'ai', items: ['ai'] }, ...(this.options.toolbar1 || [])];
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
92
103
|
this.init();
|
|
93
104
|
this.preloadIcons();
|
|
94
105
|
}
|
|
@@ -449,6 +460,7 @@ class Toolbar extends Module {
|
|
|
449
460
|
'clear-format': 'Clear Formatting',
|
|
450
461
|
'text-direction': 'Toggle Text Direction (LTR/RTL)',
|
|
451
462
|
'find': 'Find & Replace (Ctrl+F)',
|
|
463
|
+
'ai': 'AI assistant',
|
|
452
464
|
|
|
453
465
|
'import': 'Import Files',
|
|
454
466
|
'code-view': 'Switch to HTML Editor',
|
package/lib/styles.css
CHANGED
|
@@ -3439,6 +3439,92 @@
|
|
|
3439
3439
|
.yjd-mention-name { font-weight: 500; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
3440
3440
|
@media (prefers-reduced-motion: reduce) { .yjd-mention-menu { animation: none; } }
|
|
3441
3441
|
|
|
3442
|
+
/* ── AI assistant: selection toolbar + ghost text ─────────────────────── */
|
|
3443
|
+
.yjd-ai-bar {
|
|
3444
|
+
position: absolute;
|
|
3445
|
+
z-index: 2100;
|
|
3446
|
+
width: 320px;
|
|
3447
|
+
max-width: calc(100vw - 16px);
|
|
3448
|
+
padding: 8px;
|
|
3449
|
+
background: var(--rte-bg, #ffffff);
|
|
3450
|
+
border: 1px solid var(--rte-border, #e9e9f1);
|
|
3451
|
+
border-radius: var(--rte-radius-md, 12px);
|
|
3452
|
+
box-shadow: var(--rte-shadow, 0 12px 32px -8px rgba(20, 24, 46, 0.20), 0 4px 10px -4px rgba(20, 24, 46, 0.10));
|
|
3453
|
+
font: 14px/1.4 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
|
3454
|
+
color: var(--rte-ink, #20242f);
|
|
3455
|
+
animation: yjd-slash-in 90ms ease-out;
|
|
3456
|
+
}
|
|
3457
|
+
.yjd-ai-actions { display: flex; flex-wrap: wrap; gap: 4px; }
|
|
3458
|
+
.yjd-ai-act {
|
|
3459
|
+
flex: 1 1 auto;
|
|
3460
|
+
padding: 6px 10px;
|
|
3461
|
+
border: none;
|
|
3462
|
+
background: transparent;
|
|
3463
|
+
border-radius: 8px;
|
|
3464
|
+
cursor: pointer;
|
|
3465
|
+
text-align: left;
|
|
3466
|
+
white-space: nowrap;
|
|
3467
|
+
color: var(--rte-ink, #20242f);
|
|
3468
|
+
font: inherit;
|
|
3469
|
+
}
|
|
3470
|
+
.yjd-ai-act:hover { background: var(--rte-accent-weak, #efedff); color: var(--rte-accent-ink, #5a48ee); }
|
|
3471
|
+
.yjd-ai-ask { margin-top: 6px; }
|
|
3472
|
+
.yjd-ai-input {
|
|
3473
|
+
width: 100%;
|
|
3474
|
+
box-sizing: border-box;
|
|
3475
|
+
padding: 8px 10px;
|
|
3476
|
+
border: 1px solid var(--rte-border, #e9e9f1);
|
|
3477
|
+
border-radius: 8px;
|
|
3478
|
+
font: inherit;
|
|
3479
|
+
color: var(--rte-ink, #20242f);
|
|
3480
|
+
background: var(--rte-bg, #fff);
|
|
3481
|
+
}
|
|
3482
|
+
.yjd-ai-input:focus {
|
|
3483
|
+
outline: none;
|
|
3484
|
+
border-color: var(--rte-accent, #6d5efc);
|
|
3485
|
+
box-shadow: 0 0 0 3px var(--rte-accent-ring, rgba(109, 94, 252, 0.28));
|
|
3486
|
+
}
|
|
3487
|
+
.yjd-ai-result {
|
|
3488
|
+
max-height: 220px;
|
|
3489
|
+
overflow-y: auto;
|
|
3490
|
+
padding: 8px 10px;
|
|
3491
|
+
border-radius: 8px;
|
|
3492
|
+
background: var(--rte-accent-weak, #efedff);
|
|
3493
|
+
color: var(--rte-ink, #20242f);
|
|
3494
|
+
white-space: pre-wrap;
|
|
3495
|
+
word-break: break-word;
|
|
3496
|
+
}
|
|
3497
|
+
.yjd-ai-result.is-error { background: #fdecec; color: #b3261e; }
|
|
3498
|
+
.yjd-ai-foot { display: flex; gap: 6px; margin-top: 8px; }
|
|
3499
|
+
.yjd-ai-foot button {
|
|
3500
|
+
flex: 1 1 auto;
|
|
3501
|
+
padding: 6px 10px;
|
|
3502
|
+
border-radius: 8px;
|
|
3503
|
+
border: 1px solid var(--rte-border, #e9e9f1);
|
|
3504
|
+
background: var(--rte-bg, #fff);
|
|
3505
|
+
color: var(--rte-ink, #20242f);
|
|
3506
|
+
cursor: pointer;
|
|
3507
|
+
font: inherit;
|
|
3508
|
+
}
|
|
3509
|
+
.yjd-ai-accept { background: var(--rte-accent, #6d5efc) !important; color: var(--rte-accent-ink-on, #fff) !important; border-color: var(--rte-accent, #6d5efc) !important; }
|
|
3510
|
+
.yjd-ai-foot button:hover { filter: brightness(0.97); }
|
|
3511
|
+
.yjd-ai-ghost {
|
|
3512
|
+
position: absolute;
|
|
3513
|
+
z-index: 1;
|
|
3514
|
+
pointer-events: none;
|
|
3515
|
+
color: var(--rte-muted, #9aa0b4);
|
|
3516
|
+
white-space: pre;
|
|
3517
|
+
font: inherit;
|
|
3518
|
+
}
|
|
3519
|
+
@media (prefers-reduced-motion: reduce) { .yjd-ai-bar { animation: none; } }
|
|
3520
|
+
|
|
3521
|
+
/* AI toolbar button — accent-tinted so the assistant reads as "special". */
|
|
3522
|
+
.rich-editor-toolbar-btn.ai-btn { color: var(--rte-accent, #6d5efc); }
|
|
3523
|
+
.rich-editor-toolbar-btn.ai-btn:hover {
|
|
3524
|
+
background: var(--rte-accent-weak, #efedff);
|
|
3525
|
+
color: var(--rte-accent-ink, #5a48ee);
|
|
3526
|
+
}
|
|
3527
|
+
|
|
3442
3528
|
/* ── Static read-view (.yjd-content) (v2.24) ──────────────────────────────
|
|
3443
3529
|
renderStatic() tags its host with .yjd-content. These rules mirror the
|
|
3444
3530
|
editor's content typography so a saved post renders identically without an
|