Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 5 additions & 1 deletion packages/quill/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
23 changes: 23 additions & 0 deletions packages/quill/src/assets/core.styl
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
26 changes: 26 additions & 0 deletions packages/quill/src/blots/suggestion-text.ts
Original file line number Diff line number Diff line change
@@ -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;
4 changes: 4 additions & 0 deletions packages/quill/src/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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,
});
Expand Down
188 changes: 188 additions & 0 deletions packages/quill/src/core/quill.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -81,6 +82,7 @@ class Quill {
keyboard: true,
history: true,
uploader: true,
suggestions: true,
},
placeholder: '',
readOnly: false,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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,
() => {
Expand Down Expand Up @@ -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) {
Expand Down
2 changes: 2 additions & 0 deletions packages/quill/src/core/theme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> & {
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading