@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/README.md +92 -5
- 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 +98 -6
- package/index.js +6 -94
- package/lib/core/editor.js +173 -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
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
|
-
|
|
152
|
-
|
|
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
|
};
|
package/lib/core/editor.js
CHANGED
|
@@ -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
|
|