diff --git a/package-lock.json b/package-lock.json index f8f488d1f6..8c07018bf2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19240,7 +19240,7 @@ } }, "packages/quill": { - "version": "2.0.4-beta.15", + "version": "2.0.4-beta.26", "license": "BSD-3-Clause", "dependencies": { "eventemitter3": "^5.0.1", diff --git a/packages/quill/package.json b/packages/quill/package.json index 167716b3ee..91901befda 100644 --- a/packages/quill/package.json +++ b/packages/quill/package.json @@ -76,10 +76,14 @@ ], "scripts": { "build": "./scripts/build production", + "build:watch": "webpack --mode development --watch", + "dev": "npm run build:dev && npm run build:watch", "lint": "run-s lint:*", "lint:eslint": "eslint .", "lint:tsc": "tsc --noEmit --skipLibCheck", - "start": "[[ -z \"$npm_package_config_ports_webpack\" ]] && webpack-dev-server || webpack-dev-server --port $npm_package_config_ports_webpack", + "start": "webpack serve --mode development --open", + "start:quill": "npm run start", + "build:dev": "webpack --mode development", "test": "run-s test:*", "test:unit": "vitest --config test/unit/vitest.config.ts", "test:fuzz": "vitest --config test/fuzz/vitest.config.ts", diff --git a/packages/quill/src/assets/core.styl b/packages/quill/src/assets/core.styl index b4645ca6fc..66c0620380 100644 --- a/packages/quill/src/assets/core.styl +++ b/packages/quill/src/assets/core.styl @@ -202,6 +202,29 @@ resets(arr) .ql-align-right text-align: right +// Suggestion text styling for hackathon prototype +.ql-suggestion-text + color: #999 + font-style: italic + user-select: none + pointer-events: none + position: relative + + // Animation + animation: suggestionFadeIn 0.3s ease-in-out + +@keyframes suggestionFadeIn + from + opacity: 0 + transform: translateY(-1px) + to + opacity: 1 + transform: translateY(0) + +// Suggestions mode indicator +.ql-editor.ql-suggestions-mode + // Optional: add mode indicators if needed + .ql-ui position: absolute diff --git a/packages/quill/src/blots/suggestion-text.ts b/packages/quill/src/blots/suggestion-text.ts new file mode 100644 index 0000000000..594fa1daa1 --- /dev/null +++ b/packages/quill/src/blots/suggestion-text.ts @@ -0,0 +1,26 @@ +import Inline from './inline.js'; + +class SuggestionTextBlot extends Inline { + static blotName = 'suggestion-text'; + static className = 'ql-suggestion-text'; + static tagName = 'SPAN'; + + static create() { + const node = super.create(); + // Don't use contenteditable=false as it disrupts cursor positioning + // Instead rely on CSS to make it non-interactive + node.classList.add(this.className); + return node; + } + + static formats() { + return true; + } + + // Prevent merging with regular inline blots + optimize() { + // Skip optimization to maintain suggestion separation + } +} + +export default SuggestionTextBlot; diff --git a/packages/quill/src/core.ts b/packages/quill/src/core.ts index 65d0df47d9..512d935995 100644 --- a/packages/quill/src/core.ts +++ b/packages/quill/src/core.ts @@ -16,11 +16,13 @@ import Inline from './blots/inline.js'; import Scroll from './blots/scroll.js'; import TextBlot from './blots/text.js'; import SoftBreak from './blots/soft-break.js'; +import SuggestionTextBlot from './blots/suggestion-text.js'; import Clipboard from './modules/clipboard.js'; import History from './modules/history.js'; import Keyboard from './modules/keyboard.js'; import Uploader from './modules/uploader.js'; +import Suggestions from './modules/suggestions.js'; import Delta, { Op, OpIterator, AttributeMap } from 'quill-delta'; import Input from './modules/input.js'; import UINode from './modules/uiNode.js'; @@ -46,11 +48,13 @@ Quill.register({ 'blots/inline': Inline, 'blots/scroll': Scroll, 'blots/text': TextBlot, + 'blots/suggestion-text': SuggestionTextBlot, 'modules/clipboard': Clipboard, 'modules/history': History, 'modules/keyboard': Keyboard, 'modules/uploader': Uploader, + 'modules/suggestions': Suggestions, 'modules/input': Input, 'modules/uiNode': UINode, }); diff --git a/packages/quill/src/core/quill.ts b/packages/quill/src/core/quill.ts index c8ee8b89a1..9455bca6ba 100644 --- a/packages/quill/src/core/quill.ts +++ b/packages/quill/src/core/quill.ts @@ -9,6 +9,7 @@ import type Clipboard from '../modules/clipboard.js'; import type History from '../modules/history.js'; import type Keyboard from '../modules/keyboard.js'; import type Uploader from '../modules/uploader.js'; +import type Suggestions from '../modules/suggestions.js'; import Editor from './editor.js'; import Emitter from './emitter.js'; import type { EmitterSource } from './emitter.js'; @@ -81,6 +82,7 @@ class Quill { keyboard: true, history: true, uploader: true, + suggestions: true, }, placeholder: '', readOnly: false, @@ -189,11 +191,16 @@ class Quill { clipboard: Clipboard; history: History; uploader: Uploader; + suggestions: Suggestions; tempFocusHolder: HTMLInputElement; options: ExpandedQuillOptions; + // Suggestions state + private isSuggestionsMode: boolean = false; + private primaryDelta: Delta | null = null; + constructor(container: HTMLElement | string, options: QuillOptions = {}) { this.options = expandConfig(container, options); this.container = this.options.container; @@ -229,6 +236,7 @@ class Quill { this.clipboard = this.theme.addModule('clipboard'); this.history = this.theme.addModule('history'); this.uploader = this.theme.addModule('uploader'); + this.suggestions = this.theme.addModule('suggestions'); this.theme.addModule('input'); this.theme.addModule('uiNode'); this.theme.init(); @@ -619,6 +627,23 @@ class Quill { // eslint-disable-next-line prefer-const // @ts-expect-error [index, , formats, source] = overload(index, 0, name, value, source); + + // Handle suggestions mode + if (this.isSuggestionsMode) { + return modify.call( + this, + () => { + // Insert text with suggestion format + return this.editor.insertText(index, text, { + 'suggestion-text': true, + }); + }, + source, + index, + 0, + ); + } + return modify.call( this, () => { @@ -778,6 +803,169 @@ class Quill { true, ); } + + // Suggestions API methods + suggestionsStart(): void { + if (this.isSuggestionsMode) return; + + this.primaryDelta = this.getContents(); + this.isSuggestionsMode = true; + this.scroll.batchStart(); + this.container.classList.add('ql-suggestions-mode'); + } + + suggestionsAccept(): Delta { + if (!this.isSuggestionsMode) return new Delta(); + + // Convert suggestion blots to regular text blots + const changes = this.convertSuggestionsToText(); + this.scroll.batchEnd(); + this.container.classList.remove('ql-suggestions-mode'); + this.isSuggestionsMode = false; + + if (changes.length() > 0) { + this.emitter.emit( + Emitter.events.TEXT_CHANGE, + changes, + this.primaryDelta, + Emitter.sources.USER, + ); + } + + return changes; + } + + suggestionsCancel(): void { + if (!this.isSuggestionsMode) return; + + this.removeSuggestionBlots(); + this.scroll.batchEnd(); + this.container.classList.remove('ql-suggestions-mode'); + this.isSuggestionsMode = false; + } + + suggestionsAcceptFirstWord(): Delta { + if (!this.isSuggestionsMode) return new Delta(); + + // Find suggestion blots and extract the first word + const suggestionBlots = this.scroll.descendants( + (blot: any) => blot.statics.className === 'ql-suggestion-text', + ); + + if (suggestionBlots.length === 0) return new Delta(); + + // Get the first suggestion blot to work with + const firstSuggestionBlot = suggestionBlots[0] as any; + const suggestionText = firstSuggestionBlot.domNode.textContent || ''; + + // Extract first word (including leading space if present) + const match = suggestionText.match(/^(\s*\S+)/); + if (!match) return new Delta(); + + const firstWord = match[1]; + const remainingText = suggestionText.slice(firstWord.length); + + // Get the position of the suggestion in the document + const suggestionIndex = this.getIndex(firstSuggestionBlot); + + // Remove the entire suggestion blot first + firstSuggestionBlot.remove(); + + // Insert the first word as permanent text + const changes = new Delta().retain(suggestionIndex).insert(firstWord); + + // If there's remaining text, insert it as a new suggestion + if (remainingText.trim()) { + this.insertText(suggestionIndex + firstWord.length, remainingText, { + 'suggestion-text': true, + }); + } else { + // No more suggestion text, exit suggestions mode + this.scroll.batchEnd(); + this.container.classList.remove('ql-suggestions-mode'); + this.isSuggestionsMode = false; + } + + // Position cursor after the accepted word + this.setSelection(suggestionIndex + firstWord.length, 0); + + // Emit the change + if (changes.length() > 0) { + this.emitter.emit( + Emitter.events.TEXT_CHANGE, + changes, + this.primaryDelta, + Emitter.sources.USER, + ); + } + + // Emit custom event for partial acceptance + this.emitter.emit('suggestion-partial-accepted', { + acceptedText: firstWord, + remainingText: remainingText.trim(), + position: suggestionIndex, + }); + + return changes; + } + + private convertSuggestionsToText(): Delta { + const currentDelta = this.getContents(); + const cleanDelta = new Delta(); + let suggestionEndIndex = 0; + let hasSuggestion = false; + + // Create a new delta without suggestion formatting + currentDelta.ops.forEach((op) => { + if ( + op.insert && + typeof op.insert === 'string' && + op.attributes && + op.attributes['suggestion-text'] + ) { + // This is suggestion text - keep the text but remove the suggestion format + const cleanAttributes = { ...op.attributes }; + delete cleanAttributes['suggestion-text']; + cleanDelta.insert( + op.insert, + Object.keys(cleanAttributes).length > 0 ? cleanAttributes : undefined, + ); + suggestionEndIndex += op.insert.length; + hasSuggestion = true; + } else { + cleanDelta.push(op); + if (!hasSuggestion && typeof op.insert === 'string') { + suggestionEndIndex += op.insert.length; + } + } + }); + + // Apply the cleaned content + this.setContents(cleanDelta, Emitter.sources.SILENT); + + // Position cursor at the end of the accepted suggestion + if (hasSuggestion) { + this.setSelection(suggestionEndIndex, 0, Emitter.sources.SILENT); + } + + // Calculate what changed + const changes = this.primaryDelta + ? cleanDelta.diff(this.primaryDelta) + : new Delta(); + return changes; + } + + private removeSuggestionBlots(): void { + // Use the blot-based approach instead of Delta manipulation + // to avoid cursor position issues + const suggestionBlots = this.scroll.descendants( + (blot: any) => blot.statics.className === 'ql-suggestion-text', + ); + + suggestionBlots.forEach((blot: any) => { + blot.remove(); + }); + } } function resolveSelector(selector: string | HTMLElement | null | undefined) { diff --git a/packages/quill/src/core/theme.ts b/packages/quill/src/core/theme.ts index a23d212270..ae2f0bf181 100644 --- a/packages/quill/src/core/theme.ts +++ b/packages/quill/src/core/theme.ts @@ -4,6 +4,7 @@ import type History from '../modules/history.js'; import type Keyboard from '../modules/keyboard.js'; import type { ToolbarProps } from '../modules/toolbar.js'; import type Uploader from '../modules/uploader.js'; +import type Suggestions from '../modules/suggestions.js'; export interface ThemeOptions { modules: Record & { @@ -39,6 +40,7 @@ class Theme { addModule(name: 'keyboard'): Keyboard; addModule(name: 'uploader'): Uploader; addModule(name: 'history'): History; + addModule(name: 'suggestions'): Suggestions; addModule(name: string): unknown; addModule(name: string) { // @ts-expect-error diff --git a/packages/quill/src/modules/suggestions.ts b/packages/quill/src/modules/suggestions.ts new file mode 100644 index 0000000000..e6b81d0770 --- /dev/null +++ b/packages/quill/src/modules/suggestions.ts @@ -0,0 +1,214 @@ +import Module from '../core/module.js'; +import Quill from '../core/quill.js'; +import type { Range } from '../core/selection.js'; + +export interface SuggestionsOptions { + /** + * Callback function to get inline suggestion text + * Should return a Promise that resolves to suggestion text or null + */ + getInlineSuggestion?: (context: SuggestionContext) => Promise; +} + +export interface SuggestionContext { + /** Current text in the editor */ + text: string; + /** Current cursor position */ + index: number; + /** Text before cursor */ + prefix: string; + /** Text after cursor */ + suffix: string; + /** Current selection range */ + range: Range | null; + /** Current formatting at cursor */ + format: Record; +} + +class Suggestions extends Module { + static DEFAULTS: SuggestionsOptions = { + getInlineSuggestion: undefined, + }; + + private isActive = false; + + constructor(quill: Quill, options: Partial) { + super(quill, options); + + this.setupKeyboardBindings(); + this.setupEventListeners(); + } + + private setupKeyboardBindings() { + // Cmd+; (semicolon) to trigger suggestions + this.quill.keyboard.addBinding( + { key: ';', shortKey: true }, + this.triggerSuggestion.bind(this), + ); + + // Add our tab handler first - it will handle suggestions or pass through to default + const keyboard = this.quill.getModule('keyboard') as any; + + // Create our tab handler + const ourTabHandler = { + key: 'Tab', + handler: () => { + // Handle suggestions first + if (this.isActive) { + this.acceptSuggestion(); + return false; // Prevent default tab behavior + } + + // Not in suggestion mode - let default tab behavior continue + return true; + }, + }; + + // Ensure our handler runs first by prepending to the bindings array + if (!keyboard.bindings) keyboard.bindings = {}; + if (!keyboard.bindings['Tab']) keyboard.bindings['Tab'] = []; + + // Insert our handler at the beginning + keyboard.bindings['Tab'].unshift(ourTabHandler); + + // Escape to cancel suggestions + this.quill.keyboard.addBinding({ key: 'Escape' }, () => { + if (this.isActive) { + this.cancelSuggestion(); + return false; // Prevent default escape behavior + } + }); + } + + private setupEventListeners() { + // Listen for selection changes to auto-cancel suggestions + this.quill.on( + Quill.events.SELECTION_CHANGE, + (range: Range | null, oldRange: Range | null) => { + if (this.isActive && range && oldRange) { + // Cancel suggestions if cursor moves away from suggestion area + if (range.index !== oldRange.index) { + this.cancelSuggestion(); + } + } + }, + ); + } + + /** + * Trigger inline suggestion at current cursor position + */ + async triggerSuggestion(): Promise { + // Get current range first, before any cancellation + const range = this.quill.getSelection(); + if (!range || range.length > 0) return; // Only work with collapsed selection + + // If already active, cancel current suggestion first + if (this.isActive) { + this.cancelSuggestion(); + // Restore cursor position after cancellation + this.quill.setSelection(range.index, 0); + } + + const context = this.buildSuggestionContext(range); + + try { + const suggestionText = await this.getSuggestionText(context); + if (suggestionText && suggestionText.trim()) { + this.showSuggestion(suggestionText, range.index); + } + } catch (error) { + console.warn('Failed to get suggestion:', error); + } + } + + /** + * Accept the current suggestion + */ + acceptSuggestion(): void { + if (!this.isActive) return; + + // Use Quill's built-in suggestions API + const changes = this.quill.suggestionsAccept(); + this.isActive = false; + + // Emit custom event for external listeners + this.quill.emitter.emit('suggestion-accepted', changes); + } + + /** + * Cancel the current suggestion + */ + cancelSuggestion(): void { + if (!this.isActive) return; + + // Use Quill's built-in suggestions API + this.quill.suggestionsCancel(); + this.isActive = false; + + // Emit custom event for external listeners + this.quill.emitter.emit('suggestion-cancelled'); + } + + /** + * Check if suggestions are currently active + */ + isActiveState(): boolean { + return this.isActive; + } + + private buildSuggestionContext(range: Range): SuggestionContext { + const text = this.quill.getText(); + const index = range.index; + const prefix = text.substring(0, index); + const suffix = text.substring(index); + const format = this.quill.getFormat(range); + + return { + text, + index, + prefix, + suffix, + range, + format, + }; + } + + private async getSuggestionText( + context: SuggestionContext, + ): Promise { + if (this.options.getInlineSuggestion) { + return await this.options.getInlineSuggestion(context); + } + + // Default mock suggestion for development + return this.getDefaultSuggestion(context); + } + + private getDefaultSuggestion(context: SuggestionContext): string { + const mockSuggestions = [ + ' This is an AI-generated suggestion based on your content.', + " Here's a contextual completion for your text.", + ' Consider adding this relevant information to enhance your writing.', + ' This suggestion continues your thought with additional context.', + ]; + + // Simple context-aware selection + const lastWord = context.prefix.split(/\s+/).pop() || ''; + const suggestionIndex = lastWord.length % mockSuggestions.length; + + return mockSuggestions[suggestionIndex]; + } + + private showSuggestion(text: string, index: number): void { + // Use Quill's built-in suggestions API + this.quill.suggestionsStart(); + this.quill.insertText(index, text); + this.isActive = true; + + // Emit custom event for external listeners + this.quill.emitter.emit('suggestion-shown', { text, index }); + } +} + +export default Suggestions; diff --git a/packages/quill/src/playground/index.html b/packages/quill/src/playground/index.html new file mode 100644 index 0000000000..a34a280ea7 --- /dev/null +++ b/packages/quill/src/playground/index.html @@ -0,0 +1,489 @@ + + + + + 🤖 Quill Suggestions Playground + + + + + +
+
+

🤖 Quill Suggestions Playground

+

Hackathon prototype for AI autocompletion feature

+ Generated by webpack HtmlPlugin â€ĸ Built: <%= buildTime %> â€ĸ Mode: <%= env %> +
+ +
+
+

Hello world!

+

Welcome to the Quill Suggestions playground. Start typing and test the AI autocompletion features below.

+


+
+ +
+
+

🤖 Suggestions API Testing

+
+ + + +
+
+ + + +
+
Initializing...
+
+ đŸŽ¯ New Shortcuts: Cmd+; (trigger), Tab (accept), Esc (cancel)
+ Legacy: Cmd+Enter (suggest), Cmd+S (accept), Cmd+D (cancel) +
+
+ +
+

â„šī¸ Development Info

+
    +
  • Server: localhost:9080
  • +
  • Auto-rebuild: ✅ Enabled
  • +
  • Source maps: ✅ Available
  • +
  • Template: HtmlWebpackPlugin
  • +
  • Build mode: <%= env %>
  • +
+ +
+ API Implementation Progress:
+
+ suggestionsStart() + suggestionsAccept() + suggestionsCancel() +
+
+ +
+ Next Steps:
+ 1. Edit Quill source files in src/
+ 2. Webpack auto-rebuilds on changes
+ 3. Test API methods here immediately +
+
+
+
+
+ + + + + diff --git a/packages/quill/src/themes/base.ts b/packages/quill/src/themes/base.ts index 9a94165ee8..c6c2048703 100644 --- a/packages/quill/src/themes/base.ts +++ b/packages/quill/src/themes/base.ts @@ -12,6 +12,7 @@ import type Clipboard from '../modules/clipboard.js'; import type History from '../modules/history.js'; import type Keyboard from '../modules/keyboard.js'; import type Uploader from '../modules/uploader.js'; +import type Suggestions from '../modules/suggestions.js'; import type Selection from '../core/selection.js'; const ALIGNS = [false, 'center', 'right', 'justify']; @@ -97,6 +98,7 @@ class BaseTheme extends Theme { addModule(name: 'keyboard'): Keyboard; addModule(name: 'uploader'): Uploader; addModule(name: 'history'): History; + addModule(name: 'suggestions'): Suggestions; addModule(name: 'selection'): Selection; addModule(name: string): unknown; addModule(name: string) { diff --git a/packages/quill/webpack.common.cjs b/packages/quill/webpack.common.cjs index cd370cb061..6d393c9fa9 100644 --- a/packages/quill/webpack.common.cjs +++ b/packages/quill/webpack.common.cjs @@ -2,6 +2,7 @@ const { resolve } = require('path'); const MiniCssExtractPlugin = require('mini-css-extract-plugin'); +const HtmlWebpackPlugin = require('html-webpack-plugin'); const tsRules = { test: /\.ts$/, @@ -65,5 +66,15 @@ module.exports = { new MiniCssExtractPlugin({ filename: '[name]', }), + new HtmlWebpackPlugin({ + template: resolve(__dirname, 'src/playground/index.html'), + filename: resolve(__dirname, 'dist/index.html'), + inject: false, // Manual control over script/CSS loading + minify: false, + templateParameters: { + buildTime: new Date().toISOString(), + env: process.env.NODE_ENV || 'development', + }, + }), ], }; diff --git a/packages/quill/webpack.config.cjs b/packages/quill/webpack.config.cjs index 41d6c907f8..89a1cba63d 100644 --- a/packages/quill/webpack.config.cjs +++ b/packages/quill/webpack.config.cjs @@ -32,11 +32,21 @@ module.exports = (env) => static: { directory: resolve(__dirname, './dist'), }, - hot: false, + hot: true, + liveReload: true, allowedHosts: 'all', devMiddleware: { stats: 'minimal', + writeToDisk: true, // Write files to disk so they're available }, + watchFiles: ['src/**/*'], + port: 3000, }, stats: 'minimal', + // Enable watching in development mode + watch: !env.production, + watchOptions: { + ignored: /node_modules/, + poll: 1000, + }, });