@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/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. */
@@ -159,6 +239,8 @@ export class Editor {
159
239
  ): TextareaEditor;
160
240
  /** The contentEditable element (public — apps may attach listeners to it). */
161
241
  editor: HTMLElement;
242
+ /** The AI module, present when `ai.complete` was configured. */
243
+ ai?: AiController;
162
244
  on(event: string, handler: (data: any) => void): void;
163
245
  /** Remove a previously-added listener (symmetric with on()). */
164
246
  off(event: string, handler: (data: any) => void): void;
@@ -179,6 +261,12 @@ export class Editor {
179
261
  clear(): void;
180
262
  insertText(text: string): void;
181
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;
182
270
  clearFormatting(): void;
183
271
  insertHorizontalRule(): void;
184
272
  insertImageFile(file: File): void;
@@ -281,6 +369,7 @@ export const CodeView: any;
281
369
  export const FindReplace: any;
282
370
  export const SlashMenu: any;
283
371
  export const Mention: any;
372
+ export const Ai: any;
284
373
  export const ResizeHandles: any;
285
374
 
286
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
 
@@ -206,6 +208,7 @@ export {
206
208
  FindReplace,
207
209
  SlashMenu,
208
210
  Mention,
211
+ Ai,
209
212
 
210
213
  ResizeHandles
211
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
@@ -1471,6 +1557,9 @@ export default class Editor {
1471
1557
  if (m && m.isOpen) return true;
1472
1558
  const s = this.modules.get('slash-menu');
1473
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.
1474
1563
  // Any visible portaled popup (emoji, link, image, table…).
1475
1564
  const sel = '.yjd-mention-menu, .yjd-slash-menu, .emoji-picker-popup.visible, .link-popup, .image-popup, .video-popup, .tag-popup';
1476
1565
  return [...document.querySelectorAll(sel)].some((el) => {
@@ -1661,7 +1750,7 @@ export default class Editor {
1661
1750
  this.emit('toolbar-click', data);
1662
1751
 
1663
1752
  // Commands that should always work regardless of selection location
1664
- const alwaysAllowedCommands = ['more', 'undo', 'redo', 'code-view', 'theme', 'text-direction', 'find'];
1753
+ const alwaysAllowedCommands = ['more', 'undo', 'redo', 'code-view', 'theme', 'text-direction', 'find', 'ai'];
1665
1754
 
1666
1755
  if (alwaysAllowedCommands.includes(command)) {
1667
1756
  // These commands can execute regardless of selection location
@@ -1685,6 +1774,11 @@ export default class Editor {
1685
1774
  case 'find':
1686
1775
  // Find/replace module listens to 'toolbar-click' and opens its panel
1687
1776
  return;
1777
+ case 'ai': {
1778
+ const aiMod = this.getModule('ai');
1779
+ if (aiMod && typeof aiMod.openFromToolbar === 'function') aiMod.openFromToolbar();
1780
+ return;
1781
+ }
1688
1782
  }
1689
1783
  }
1690
1784