@oix1987/yjd 2.1.1 → 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/index.d.ts CHANGED
@@ -76,6 +76,84 @@ export interface SubmitOptions {
76
76
  newlineOnShiftEnter?: boolean;
77
77
  }
78
78
 
79
+ /** Context passed to the AI `complete` hook for one request. */
80
+ export interface AiContext {
81
+ /** Action id ('improve', 'fix', 'ask', 'autocomplete', or a custom id). */
82
+ action: string;
83
+ /** Instruction for the action (the action's prompt, or the user's question). */
84
+ prompt: string;
85
+ /** The selected text (or, for autocomplete, the preceding context). */
86
+ text: string;
87
+ /** The selected HTML, when available. */
88
+ html: string;
89
+ /** Aborts when the request is superseded or cancelled. */
90
+ signal: AbortSignal;
91
+ }
92
+
93
+ /** A selection-toolbar action. */
94
+ export interface AiAction {
95
+ id: string;
96
+ label: string;
97
+ /** Instruction handed to the `complete` hook as `prompt`. */
98
+ prompt: string;
99
+ }
100
+
101
+ /** Inline ghost-text autocomplete tuning. */
102
+ export interface AiAutocompleteOptions {
103
+ /** Idle delay before requesting a suggestion (ms, default 400). */
104
+ debounce?: number;
105
+ /** Minimum context length before suggesting (default 3). */
106
+ minChars?: number;
107
+ /** Max characters of preceding text sent as context (default 600). */
108
+ maxContext?: number;
109
+ }
110
+
111
+ /**
112
+ * AI configuration. Inert until a `complete` hook is given (BYO-model, like
113
+ * `mention.source`). Enables a selection toolbar (improve/fix/shorten/…/Ask AI)
114
+ * with accept-or-discard, and optional ghost-text autocomplete.
115
+ */
116
+ export interface AiOptions {
117
+ /**
118
+ * Call your LLM and resolve to the generated text. Stream by invoking
119
+ * `onToken` with each chunk; if you only stream, return undefined and the
120
+ * chunks are joined.
121
+ */
122
+ complete: (ctx: AiContext, onToken: (chunk: string) => void) => string | void | Promise<string | void>;
123
+ /** Replace/extend the selection-toolbar actions. */
124
+ actions?: AiAction[];
125
+ /** Inline ghost-text autocomplete (Tab to accept). */
126
+ autocomplete?: boolean | AiAutocompleteOptions;
127
+ }
128
+
129
+ /** Streaming sink returned by editor.streamInto(). */
130
+ export interface StreamSink {
131
+ /** Append a chunk at the caret (first append replaces the selection). */
132
+ append(chunk: string): void;
133
+ /** Finish the stream. */
134
+ commit(): void;
135
+ /** Undo the whole streamed insertion. */
136
+ cancel(): void;
137
+ }
138
+
139
+ /** A snapshot of the current selection (the context an AI/tool acts on). */
140
+ export interface SelectionSnapshot {
141
+ text: string;
142
+ html: string;
143
+ isEmpty: boolean;
144
+ range: Range;
145
+ }
146
+
147
+ /** The AI module instance, exposed as `editor.ai` when configured. */
148
+ export interface AiController {
149
+ /** Run a built-in/custom action or a free-form prompt on the selection. */
150
+ run(action: AiAction | string, opts?: { text?: string; html?: string }): Promise<string>;
151
+ /** Open the assistant bar (selection actions, or Ask-AI at the caret). */
152
+ openFromToolbar(): void;
153
+ /** Manually trigger a ghost-text completion at the caret. */
154
+ autocomplete(): void;
155
+ }
156
+
79
157
  /**
80
158
  * Toolbar configuration: a built-in preset, an exclusion of default items,
81
159
  * a flat item list (single group), or full custom groups via toolbar1/toolbar2.
@@ -126,6 +204,8 @@ export interface EditorOptions {
126
204
  mention?: MentionOptions;
127
205
  /** Enter-to-submit behaviour for comment-style editors. */
128
206
  submit?: SubmitOptions;
207
+ /** AI assistant (selection toolbar + ghost-text). Inert until `complete` is set. */
208
+ ai?: AiOptions;
129
209
  /** Built-in preset / exclusion / flat list, instead of toolbar1/toolbar2. */
130
210
  toolbar?: ToolbarOption;
131
211
  /** Warn (emit 'content:overflow') when serialized HTML exceeds this many chars. */
@@ -149,8 +229,18 @@ export interface EditorOptions {
149
229
 
150
230
  export class Editor {
151
231
  constructor(selector: string | Element, options?: EditorOptions);
232
+ /**
233
+ * Progressive-enhance a <textarea> into an editor with two-way sync + a
234
+ * controller (getValue/setValue/destroy). Available from `/core` too.
235
+ */
236
+ static fromTextarea(
237
+ textarea: HTMLTextAreaElement | string,
238
+ options?: EditorOptions & { format?: 'html' | 'markdown' }
239
+ ): TextareaEditor;
152
240
  /** The contentEditable element (public — apps may attach listeners to it). */
153
241
  editor: HTMLElement;
242
+ /** The AI module, present when `ai.complete` was configured. */
243
+ ai?: AiController;
154
244
  on(event: string, handler: (data: any) => void): void;
155
245
  /** Remove a previously-added listener (symmetric with on()). */
156
246
  off(event: string, handler: (data: any) => void): void;
@@ -171,6 +261,12 @@ export class Editor {
171
261
  clear(): void;
172
262
  insertText(text: string): void;
173
263
  insertHTML(html: string): void;
264
+ /** Snapshot of the current selection (null when outside the editor). */
265
+ getSelection(): SelectionSnapshot | null;
266
+ /** Replace the current selection with content (sanitized, undo-aware). */
267
+ replaceSelection(content: string, opts?: { asText?: boolean }): void;
268
+ /** Open a streaming sink at the caret for token-by-token AI output. */
269
+ streamInto(): StreamSink;
174
270
  clearFormatting(): void;
175
271
  insertHorizontalRule(): void;
176
272
  insertImageFile(file: File): void;
@@ -200,12 +296,7 @@ export class RichEditor extends Editor {
200
296
  static register(path: string, definition: any, suppressWarning?: boolean): void;
201
297
  static get(path: string): any;
202
298
  static create(selector: string | Element, options?: EditorOptions): RichEditor;
203
- /**
204
- * Progressive-enhance a <textarea> into an editor with TWO-WAY sync (editor
205
- * edits update textarea.value + fire native events; writing textarea.value
206
- * updates the editor). The returned editor also exposes a controller:
207
- * getValue()/setValue()/destroy() (destroy restores the textarea).
208
- */
299
+ /** Inherited from Editor (returns a fully-featured RichEditor). */
209
300
  static fromTextarea(
210
301
  textarea: HTMLTextAreaElement | string,
211
302
  options?: EditorOptions & { format?: 'html' | 'markdown' }
@@ -278,6 +369,7 @@ export const CodeView: any;
278
369
  export const FindReplace: any;
279
370
  export const SlashMenu: any;
280
371
  export const Mention: any;
372
+ export const Ai: any;
281
373
  export const ResizeHandles: any;
282
374
 
283
375
  // UI components
package/index.js CHANGED
@@ -41,6 +41,7 @@ import CodeView from './lib/modules/code-view.js';
41
41
  import FindReplace from './lib/modules/find-replace.js';
42
42
  import SlashMenu from './lib/modules/slash-menu.js';
43
43
  import Mention from './lib/modules/mention.js';
44
+ import Ai from './lib/modules/ai.js';
44
45
 
45
46
  import ResizeHandles from './lib/modules/resize-handles.js';
46
47
 
@@ -97,6 +98,7 @@ registry.register('modules/code-view', CodeView, true);
97
98
  registry.register('modules/find-replace', FindReplace, true);
98
99
  registry.register('modules/slash-menu', SlashMenu, true);
99
100
  registry.register('modules/mention', Mention, true);
101
+ registry.register('modules/ai', Ai, true);
100
102
 
101
103
  registry.register('modules/resize-handles', ResizeHandles, true);
102
104
 
@@ -147,100 +149,9 @@ class RichEditor extends Editor {
147
149
  return new RichEditor(selector, options);
148
150
  }
149
151
 
150
- /**
151
- * Progressive-enhance a <textarea>: hide it, mount an editor in its place,
152
- * and keep the textarea's value in sync so existing form submits keep working.
153
- *
154
- * const ed = RichEditor.fromTextarea(document.querySelector('#body'), {
155
- * // any editor option; `format` chooses how the textarea is read/written:
156
- * format: 'html' | 'markdown', // default 'html'
157
- * });
158
- *
159
- * The textarea's current value seeds the editor (parsed as HTML or Markdown
160
- * per `format`). Binding is TWO-WAY: editor edits update textarea.value (and
161
- * fire native input/change events), and writing `textarea.value = …` from app
162
- * code (e.g. resetting a form) updates the editor. The returned editor also
163
- * carries a small controller:
164
- *
165
- * const ed = RichEditor.fromTextarea('#body', { format: 'markdown' });
166
- * ed.setValue(md); // load new content into the editor
167
- * ed.getValue(); // current content (html or markdown per `format`)
168
- * ed.destroy(); // remove the editor, restore the textarea + last value
169
- *
170
- * @param {HTMLTextAreaElement|string} textarea Element or selector.
171
- * @param {object} [options] Editor options + optional `format`.
172
- * @returns {RichEditor}
173
- */
174
- static fromTextarea(textarea, options = {}) {
175
- const ta = typeof textarea === 'string' ? document.querySelector(textarea) : textarea;
176
- if (!ta) throw new Error('RichEditor.fromTextarea: textarea not found');
177
-
178
- const format = options.format === 'markdown' ? 'markdown' : 'html';
179
- const read = (ed) => (format === 'markdown' ? ed.getMarkdown() : ed.getContent());
180
- const write = (ed, v) => (format === 'markdown' ? ed.setMarkdown(v || '') : ed.setHTML(v || ''));
181
-
182
- // Mount point right after the textarea; hide the original.
183
- const mount = document.createElement('div');
184
- ta.after(mount);
185
- ta.style.display = 'none';
186
- ta.setAttribute('aria-hidden', 'true');
187
-
188
- // Take over textarea.value so app writes flow into the editor and reads
189
- // reflect it. Keep the native descriptor to restore on destroy + to set the
190
- // real value (for form submission) without re-triggering our setter.
191
- const nativeDesc = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value');
192
- let raw = ta.value || '';
193
- let syncing = false; // guards editor→textarea writes from re-entering setValue
194
-
195
- const initial = raw;
196
- const editor = new RichEditor(mount, {
197
- width: '100%',
198
- ...options,
199
- content: options.content != null ? options.content
200
- : (format === 'markdown' ? markdownToHtml(initial) : initial),
201
- });
202
-
203
- Object.defineProperty(ta, 'value', {
204
- configurable: true,
205
- get() { return raw; },
206
- set(v) {
207
- raw = v == null ? '' : String(v);
208
- nativeDesc.set.call(ta, raw); // keep the real textarea value for submits
209
- if (!syncing) write(editor, raw); // app write → update editor
210
- },
211
- });
212
-
213
- // editor edit → push to textarea + fire native events for app bindings.
214
- const onChange = () => {
215
- const next = read(editor);
216
- if (raw === next) return;
217
- raw = next;
218
- syncing = true;
219
- nativeDesc.set.call(ta, next);
220
- ta.dispatchEvent(new Event('input', { bubbles: true }));
221
- ta.dispatchEvent(new Event('change', { bubbles: true }));
222
- syncing = false;
223
- };
224
- editor.on('change', onChange);
225
- onChange(); // normalise textarea to the editor's serialization up front.
226
-
227
- // Controller surface on the editor instance.
228
- editor.textarea = ta;
229
- editor.getValue = () => read(editor);
230
- editor.setValue = (v) => { write(editor, v); };
231
- const baseDestroy = editor.destroy.bind(editor);
232
- editor.destroy = () => {
233
- const last = read(editor);
234
- editor.off('change', onChange);
235
- baseDestroy();
236
- mount.remove();
237
- delete ta.value; // restore the prototype's value accessor
238
- nativeDesc.set.call(ta, last);
239
- ta.style.display = '';
240
- ta.removeAttribute('aria-hidden');
241
- };
242
- return editor;
243
- }
152
+ // fromTextarea() is inherited from the base Editor (defined in core so it is
153
+ // also available from the tree-shakeable /core entry). RichEditor.fromTextarea
154
+ // therefore returns a fully-featured RichEditor (via `new this(...)`).
244
155
  }
245
156
 
246
157
  // Export classes for extension. `yjd` is the brand-aligned name; `RichEditor`
@@ -297,6 +208,7 @@ export {
297
208
  FindReplace,
298
209
  SlashMenu,
299
210
  Mention,
211
+ Ai,
300
212
 
301
213
  ResizeHandles
302
214
  };
@@ -264,6 +264,12 @@ export default class Editor {
264
264
  modulesToLoad.push('mention');
265
265
  }
266
266
 
267
+ // The AI module is inert without an `ai.complete` hook, so it is never in
268
+ // the default set — load it only when the app configures one.
269
+ if (this.options.ai && !modulesToLoad.includes('ai')) {
270
+ modulesToLoad.push('ai');
271
+ }
272
+
267
273
 
268
274
  modulesToLoad.forEach(moduleName => {
269
275
  const ModuleClass = this.registry.get(`modules/${moduleName}`);
@@ -941,6 +947,86 @@ export default class Editor {
941
947
  this.onContentChange();
942
948
  }
943
949
 
950
+ /**
951
+ * Snapshot of the current selection — the context an AI action (or any tool)
952
+ * needs to operate on what the user picked. Returns null when the selection
953
+ * is outside this editor.
954
+ * @returns {{text:string, html:string, isEmpty:boolean, range:Range}|null}
955
+ */
956
+ getSelection() {
957
+ const sel = window.getSelection();
958
+ if (!sel || !sel.rangeCount) return null;
959
+ const range = sel.getRangeAt(0);
960
+ if (!this.editor.contains(range.commonAncestorContainer)) return null;
961
+ const holder = document.createElement('div');
962
+ holder.appendChild(range.cloneContents());
963
+ return {
964
+ text: range.toString(),
965
+ html: holder.innerHTML,
966
+ isEmpty: range.collapsed,
967
+ // A detached clone, so the snapshot doesn't mutate as the caret moves.
968
+ range: range.cloneRange(),
969
+ };
970
+ }
971
+
972
+ /**
973
+ * Replace the current selection with new content (sanitized, undo-aware). The
974
+ * write path for AI rewrites and any "replace what I selected" tool. With a
975
+ * collapsed selection it inserts at the caret.
976
+ * @param {string} content
977
+ * @param {{asText?: boolean}} [opts] - insert as plain text instead of HTML
978
+ */
979
+ replaceSelection(content, opts = {}) {
980
+ if (typeof content !== 'string') return;
981
+ const history = this.getModule('history');
982
+ if (history && typeof history.saveBeforeFormat === 'function') history.saveBeforeFormat();
983
+ this.focus();
984
+ if (opts.asText) {
985
+ execFormat('insertText', content);
986
+ } else {
987
+ execFormat('insertHTML', sanitizeHtml(content));
988
+ }
989
+ this.onContentChange();
990
+ }
991
+
992
+ /**
993
+ * Open a streaming sink at the current caret/selection so an AI response can
994
+ * be written token-by-token. The first append replaces the selection; later
995
+ * appends extend it. Designed for the "watch it type" UX.
996
+ *
997
+ * const s = editor.streamInto();
998
+ * for await (const chunk of res) s.append(chunk);
999
+ * s.commit(); // or s.cancel() to undo the whole stream
1000
+ *
1001
+ * @returns {{append(t:string):void, commit():void, cancel():void}}
1002
+ */
1003
+ streamInto() {
1004
+ const history = this.getModule('history');
1005
+ if (history && typeof history.saveBeforeFormat === 'function') history.saveBeforeFormat();
1006
+ this.focus();
1007
+ // Snapshot the pre-stream HTML so cancel() can restore it in one step —
1008
+ // a stream produces several history entries, so a single undo() wouldn't
1009
+ // roll back the whole thing.
1010
+ const snapshot = this.editor.innerHTML;
1011
+ let inserted = '';
1012
+ const append = (chunk) => {
1013
+ if (typeof chunk !== 'string' || chunk === '') return;
1014
+ execFormat('insertText', chunk);
1015
+ inserted += chunk;
1016
+ this.onContentChange();
1017
+ };
1018
+ return {
1019
+ append,
1020
+ commit: () => { this.onContentChange(); },
1021
+ cancel: () => {
1022
+ if (inserted) {
1023
+ this.editor.innerHTML = snapshot;
1024
+ this.onContentChange();
1025
+ }
1026
+ },
1027
+ };
1028
+ }
1029
+
944
1030
  /**
945
1031
  * Handle a paste event: sanitize pasted HTML, or paste as plain text.
946
1032
  * @param {ClipboardEvent} e
@@ -1090,6 +1176,84 @@ export default class Editor {
1090
1176
  return `${n >= 10 || i === 0 ? Math.round(n) : n.toFixed(1)} ${u[i]}`;
1091
1177
  }
1092
1178
 
1179
+ /**
1180
+ * Progressive-enhance a <textarea> into an editor with TWO-WAY sync, returning
1181
+ * the editor with a controller (getValue/setValue/destroy). Defined on the
1182
+ * base Editor so it is available from the tree-shakeable `/core` entry too
1183
+ * (no need to pull the all-in-one build just for fromTextarea).
1184
+ *
1185
+ * const ed = Editor.fromTextarea('#body', { format: 'markdown' });
1186
+ *
1187
+ * @param {HTMLTextAreaElement|string} textarea
1188
+ * @param {object} [options] Editor options + optional `format: 'html'|'markdown'`.
1189
+ * @returns {Editor}
1190
+ */
1191
+ static fromTextarea(textarea, options = {}) {
1192
+ const ta = typeof textarea === 'string' ? document.querySelector(textarea) : textarea;
1193
+ if (!ta) throw new Error('Editor.fromTextarea: textarea not found');
1194
+
1195
+ const format = options.format === 'markdown' ? 'markdown' : 'html';
1196
+ const read = (ed) => (format === 'markdown' ? ed.getMarkdown() : ed.getContent());
1197
+ const writeVal = (ed, v) => (format === 'markdown' ? ed.setMarkdown(v || '') : ed.setHTML(v || ''));
1198
+
1199
+ const mount = document.createElement('div');
1200
+ ta.after(mount);
1201
+ ta.style.display = 'none';
1202
+ ta.setAttribute('aria-hidden', 'true');
1203
+
1204
+ const nativeDesc = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value');
1205
+ let raw = ta.value || '';
1206
+ let syncing = false;
1207
+
1208
+ const initial = raw;
1209
+ // `this` is the class fromTextarea was called on (Editor or a subclass).
1210
+ const editor = new this(mount, {
1211
+ width: '100%',
1212
+ ...options,
1213
+ content: options.content != null ? options.content
1214
+ : (format === 'markdown' ? markdownToHtml(initial) : initial),
1215
+ });
1216
+
1217
+ Object.defineProperty(ta, 'value', {
1218
+ configurable: true,
1219
+ get() { return raw; },
1220
+ set(v) {
1221
+ raw = v == null ? '' : String(v);
1222
+ nativeDesc.set.call(ta, raw);
1223
+ if (!syncing) writeVal(editor, raw);
1224
+ },
1225
+ });
1226
+
1227
+ const onChange = () => {
1228
+ const next = read(editor);
1229
+ if (raw === next) return;
1230
+ raw = next;
1231
+ syncing = true;
1232
+ nativeDesc.set.call(ta, next);
1233
+ ta.dispatchEvent(new Event('input', { bubbles: true }));
1234
+ ta.dispatchEvent(new Event('change', { bubbles: true }));
1235
+ syncing = false;
1236
+ };
1237
+ editor.on('change', onChange);
1238
+ onChange();
1239
+
1240
+ editor.textarea = ta;
1241
+ editor.getValue = () => read(editor);
1242
+ editor.setValue = (v) => { writeVal(editor, v); };
1243
+ const baseDestroy = editor.destroy.bind(editor);
1244
+ editor.destroy = () => {
1245
+ const last = read(editor);
1246
+ editor.off('change', onChange);
1247
+ baseDestroy();
1248
+ mount.remove();
1249
+ delete ta.value;
1250
+ nativeDesc.set.call(ta, last);
1251
+ ta.style.display = '';
1252
+ ta.removeAttribute('aria-hidden');
1253
+ };
1254
+ return editor;
1255
+ }
1256
+
1093
1257
  /**
1094
1258
  * Open the native picker for a non-image attachment, then insert it as a
1095
1259
  * file chip via the options.file.upload hook.
@@ -1393,6 +1557,9 @@ export default class Editor {
1393
1557
  if (m && m.isOpen) return true;
1394
1558
  const s = this.modules.get('slash-menu');
1395
1559
  if (s && s.isOpen) return true;
1560
+ // Note: the AI selection bar is intentionally NOT treated as an open menu —
1561
+ // it doesn't capture Enter, so suppressing submit would only insert a stray
1562
+ // newline. Enter-to-submit stays available while the bar floats.
1396
1563
  // Any visible portaled popup (emoji, link, image, table…).
1397
1564
  const sel = '.yjd-mention-menu, .yjd-slash-menu, .emoji-picker-popup.visible, .link-popup, .image-popup, .video-popup, .tag-popup';
1398
1565
  return [...document.querySelectorAll(sel)].some((el) => {
@@ -1583,7 +1750,7 @@ export default class Editor {
1583
1750
  this.emit('toolbar-click', data);
1584
1751
 
1585
1752
  // Commands that should always work regardless of selection location
1586
- const alwaysAllowedCommands = ['more', 'undo', 'redo', 'code-view', 'theme', 'text-direction', 'find'];
1753
+ const alwaysAllowedCommands = ['more', 'undo', 'redo', 'code-view', 'theme', 'text-direction', 'find', 'ai'];
1587
1754
 
1588
1755
  if (alwaysAllowedCommands.includes(command)) {
1589
1756
  // These commands can execute regardless of selection location
@@ -1607,6 +1774,11 @@ export default class Editor {
1607
1774
  case 'find':
1608
1775
  // Find/replace module listens to 'toolbar-click' and opens its panel
1609
1776
  return;
1777
+ case 'ai': {
1778
+ const aiMod = this.getModule('ai');
1779
+ if (aiMod && typeof aiMod.openFromToolbar === 'function') aiMod.openFromToolbar();
1780
+ return;
1781
+ }
1610
1782
  }
1611
1783
  }
1612
1784