Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,5 @@ export const HOST_EDITOR_REQUEST_EVENT = "ft-host-editor-request";
export const HOST_EDITOR_REQUEST_ATTR = "data-ft-host-editor-request";
export const HOST_EDITOR_RESPONSE_ATTR = "data-ft-host-editor-response";
export const HOST_EDITOR_MAIN_WORLD_FLAG = "__ftHostEditorBridgeInstalled";
export const CURSOR_MOVE_EVENT = "ft-cursor-move";
export const CURSOR_MOVE_COUNT_ATTR = "data-ft-cursor-move-count";
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import {
CURSOR_MOVE_COUNT_ATTR,
CURSOR_MOVE_EVENT,
HOST_EDITOR_MAIN_WORLD_FLAG,
HOST_EDITOR_REQUEST_ATTR,
HOST_EDITOR_REQUEST_EVENT,
Expand Down Expand Up @@ -146,6 +148,34 @@ export function installHostEditorMainWorldBridge(doc: Document = document): void

(win as Window & { [HOST_EDITOR_MAIN_WORLD_FLAG]?: boolean })[HOST_EDITOR_MAIN_WORLD_FLAG] = true;

// Cursor movement bridge: content script (isolated world) dispatches this
// event when it needs to reposition the cursor in the main world. Running
// Selection.modify() in the main world triggers native selectionchange events
// that React-based editors (Lexical, Slate) listen for to sync their internal
// selection state.
doc.addEventListener(
CURSOR_MOVE_EVENT,
(event) => {
const source = event.composedPath()[0];
if (!(source instanceof HTMLElement)) {
return;
}
const rawCount = source.getAttribute(CURSOR_MOVE_COUNT_ATTR);
const count = rawCount ? parseInt(rawCount, 10) : 0;
if (!Number.isFinite(count) || count <= 0) {
return;
}
const sel = win.getSelection();
if (!sel) {
return;
}
for (let i = 0; i < count; i++) {
sel.modify("move", "backward", "character");
}
},
true,
);

doc.addEventListener(
HOST_EDITOR_REQUEST_EVENT,
(event) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { GrammarEdit } from "@core/domain/grammar/types";
import { SPACING_RULES, Spacing } from "@core/domain/spacingRules";
import { ContentEditableAdapter, type ContentEditableEditResult } from "./ContentEditableAdapter";
import { HostEditorAdapterResolver, type HostEditorSession } from "./HostEditorAdapterResolver";
import { CURSOR_MOVE_COUNT_ATTR, CURSOR_MOVE_EVENT } from "./HostEditorBridgeProtocol";
import { TextTargetAdapter, type TextTarget } from "./TextTargetAdapter";
import { buildCaretTrace, clipTraceText, collapseTraceWhitespace } from "./traceUtils";
import type {
Expand Down Expand Up @@ -546,12 +547,15 @@ export class SuggestionTextEditService {
blockSourceText = `${blockContext.beforeCursor}${blockContext.afterCursor}`;
expectedBlockText = blockSourceText;
expectedBlockText = `${expectedBlockText.slice(0, blockReplaceStart)}${replacement}${expectedBlockText.slice(blockReplaceEnd)}`;
expectedBlockCursorAfter = this.resolveCursorAfterTextEdit(
blockCursor,
blockReplaceStart,
blockReplaceEnd,
replacement,
);
expectedBlockCursorAfter =
edit.cursorOffset !== undefined
? blockReplaceStart + Math.max(0, Math.min(replacement.length, edit.cursorOffset))
: this.resolveCursorAfterTextEdit(
blockCursor,
blockReplaceStart,
blockReplaceEnd,
replacement,
);
blockCursorAfter = expectedBlockCursorAfter;

replaceStart = Math.max(0, blockStart + blockCursor - deleteBackwards);
Expand All @@ -578,12 +582,15 @@ export class SuggestionTextEditService {
return { applied: false, didDispatchInput: false, suppressedByManualRevert: true };
}

const cursorAfter = this.resolveCursorAfterTextEdit(
snapshot.cursorOffset,
replaceStart,
replaceEnd,
replacement,
);
const cursorAfter =
edit.cursorOffset !== undefined
? replaceStart + Math.max(0, Math.min(replacement.length, edit.cursorOffset))
: this.resolveCursorAfterTextEdit(
snapshot.cursorOffset,
replaceStart,
replaceEnd,
replacement,
);
const applyResult =
!TextTargetAdapter.isTextValue(entry.elem) &&
activeBlock !== null &&
Expand Down Expand Up @@ -611,12 +618,54 @@ export class SuggestionTextEditService {
cursorAfter,
{ preferDomMutation: this.shouldPreferDomMutationForGrammar(entry.elem) },
);
// For edits with cursorOffset on contenteditable, schedule deferred cursor
// repositioning BEFORE the didMutateDom check. React-based editors (Lexical,
// Slate) handle beforeinput by calling preventDefault() and reconciling the
// DOM asynchronously via microtask. This means didMutateDom is false at check
// time even though the edit WILL be applied. The deferred callback validates
// that the expected text appeared before moving the cursor.
if (edit.cursorOffset !== undefined && !TextTargetAdapter.isTextValue(entry.elem)) {
const moveBackCount = replacement.length - edit.cursorOffset;
if (moveBackCount > 0) {
const targetElem = entry.elem as HTMLElement;
// React-based editors (Lexical, Slate) reconcile the DOM asynchronously
// and override cursor positions set via setCaret. Dispatch a bridge event
// to the main-world script which calls Selection.modify() there. Running
// in the main world triggers native selectionchange events that host
// editors detect and use to sync their internal selection state.
let applied = false;
const applyModify = () => {
if (applied || !targetElem.isConnected) {
return;
}
// Validate that the replacement text actually appeared in the DOM
// before moving the cursor. This distinguishes async host reconciliation
// (Lexical) from true edit rejection.
const currentText = targetElem.textContent ?? "";
if (!currentText.includes(replacement)) {
return;
}
applied = true;
targetElem.setAttribute(CURSOR_MOVE_COUNT_ATTR, String(moveBackCount));
targetElem.dispatchEvent(
new CustomEvent(CURSOR_MOVE_EVENT, { bubbles: true, composed: true }),
);
targetElem.removeAttribute(CURSOR_MOVE_COUNT_ATTR);
};
// Schedule at multiple timing points to cover different editor
// reconciliation strategies. Only the first successful one applies.
requestAnimationFrame(applyModify);
setTimeout(applyModify, 30);
}
}

if (!applyResult.didMutateDom) {
return {
applied: false,
didDispatchInput: false,
};
}

let postEditSnapshot: SuggestionSnapshot | null =
!TextTargetAdapter.isTextValue(entry.elem) &&
activeBlock !== null &&
Expand Down
13 changes: 12 additions & 1 deletion src/core/domain/grammar/GrammarEditSequencing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,13 @@ export function applyGrammarEditToContext(
after = after.slice(edit.deleteForwards);
}

before += edit.replacement;
if (edit.cursorOffset !== undefined) {
const offset = Math.max(0, Math.min(edit.replacement.length, edit.cursorOffset));
before += edit.replacement.slice(0, offset);
after = edit.replacement.slice(offset) + after;
} else {
before += edit.replacement;
}

return {
...context,
Expand All @@ -32,6 +38,7 @@ export function mergeSequentialGrammarEdits(edits: GrammarEdit[]): GrammarEdit[]
let mergedConfidence: GrammarEdit["confidence"] | undefined;
let mergedSourceRuleId: GrammarEdit["sourceRuleId"] | undefined;
let mergedSafetyTier: GrammarEdit["safetyTier"] | undefined;
let mergedCursorOffset: number | undefined;
let accumulatedString = "";
let baseDeleteBackwards = 0;

Expand All @@ -53,13 +60,17 @@ export function mergeSequentialGrammarEdits(edits: GrammarEdit[]): GrammarEdit[]
if (edit.safetyTier) {
mergedSafetyTier = edit.safetyTier;
}
if (edit.cursorOffset !== undefined) {
mergedCursorOffset = keepAccumulated + edit.cursorOffset;
}
}

return [
{
replacement: accumulatedString,
deleteBackwards: baseDeleteBackwards,
deleteForwards: totalDeleteForwards,
...(mergedCursorOffset !== undefined ? { cursorOffset: mergedCursorOffset } : {}),
...(mergedConfidence ? { confidence: mergedConfidence } : {}),
...(mergedSourceRuleId ? { sourceRuleId: mergedSourceRuleId } : {}),
...(mergedSafetyTier ? { safetyTier: mergedSafetyTier } : {}),
Expand Down
132 changes: 132 additions & 0 deletions src/core/domain/grammar/implementations/AutoBracketCloseRule.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import type { GrammarContext, GrammarEdit, GrammarEventType, GrammarRule } from "../types";
import { isDeleteInputAction } from "./helpers/GenericRuleShared";

const PAIRS = new Map<string, string>([
["(", ")"],
["[", "]"],
["{", "}"],
["'", "'"],
['"', '"'],
["`", "`"],
["<", ">"],
["«", "»"],
]);

const CLOSING_TO_OPENING = new Map<string, string>();
for (const [open, close] of PAIRS) {
CLOSING_TO_OPENING.set(close, open);
}

const SYMMETRIC_QUOTES = new Set(["'", '"', "`"]);

const WORD_CHAR_REGEX = /[\p{L}\p{N}]/u;

export class AutoBracketCloseRule implements GrammarRule {
readonly id = "autoBracketClose" as const;
readonly name = "Auto-close Brackets and Quotes";
readonly triggers: GrammarEventType[] = ["insertChar"];

apply(context: GrammarContext): GrammarEdit | null {
if (isDeleteInputAction(context)) {
return null;
}

const { beforeCursor, afterCursor } = context;
if (beforeCursor.length === 0) {
return null;
}

const typed = beforeCursor[beforeCursor.length - 1];

// Check for overtype first: user typed a closing char and afterCursor starts with the same.
// For symmetric quotes (', ", `), both the opening and closing char are identical,
// so overtype fires whenever the same quote appears ahead — this is a heuristic that
// matches IDE behavior (e.g., VS Code) but may skip over non-auto-inserted quotes.
if (CLOSING_TO_OPENING.has(typed) && afterCursor.length > 0 && afterCursor[0] === typed) {
return this.handleOvertype(beforeCursor, typed);
}

// Check for auto-close: user typed an opening char
const closeChar = PAIRS.get(typed);
if (closeChar) {
return this.handleAutoClose(context, typed, closeChar);
}

return null;
}

private handleAutoClose(
context: GrammarContext,
openChar: string,
closeChar: string,
): GrammarEdit | null {
const { beforeCursor, afterCursor } = context;
const beforeOpener = beforeCursor.slice(0, -1);

// For symmetric quotes (', ", `): don't auto-close when preceded by a word character
// (it's likely an apostrophe/contraction like "it's" or closing quote)
if (SYMMETRIC_QUOTES.has(openChar)) {
if (beforeOpener.length > 0 && WORD_CHAR_REGEX.test(beforeOpener[beforeOpener.length - 1])) {
return null;
}
}

// For < bracket: don't auto-close when preceded by a word character
// (it's likely a comparison operator or HTML tag, not a quotation bracket)
if (openChar === "<") {
if (beforeOpener.length > 0 && WORD_CHAR_REGEX.test(beforeOpener[beforeOpener.length - 1])) {
return null;
}
}

// Don't auto-close if afterCursor already starts with the matching close char
// (avoids doubling: typing ( when cursor is already before ))
if (afterCursor.length > 0 && afterCursor[0] === closeChar) {
return null;
}

return {
replacement: openChar + closeChar,
deleteBackwards: 1,
deleteForwards: 0,
cursorOffset: 1,
confidence: "medium",
safetyTier: "advanced",
sourceRuleId: "autoBracketClose",
description: `Auto-closed ${openChar}${closeChar}`,
};
}

private handleOvertype(beforeCursor: string, closeChar: string): GrammarEdit | null {
const beforeTyped = beforeCursor.slice(0, -1);

// For > specifically: don't overtype when preceded by certain patterns
// that suggest comparison/shift operators (e.g., "a>", "1>", ">>")
if (closeChar === ">") {
if (beforeTyped.length > 0 && WORD_CHAR_REGEX.test(beforeTyped[beforeTyped.length - 1])) {
return null;
}
}

// For symmetric quotes: only overtype when preceded by a word character.
// This distinguishes "user closing a quote" (e.g., "hello"|) from
// "engine re-processing after auto-close" (e.g., "|) which would oscillate.
if (SYMMETRIC_QUOTES.has(closeChar)) {
if (beforeTyped.length === 0 || !WORD_CHAR_REGEX.test(beforeTyped[beforeTyped.length - 1])) {
return null;
}
}

// Cursor naturally lands at end of the single-char replacement,
// which is the correct position (after the closing char).
return {
replacement: closeChar,
deleteBackwards: 1,
deleteForwards: 1,
confidence: "high",
safetyTier: "advanced",
sourceRuleId: "autoBracketClose",
description: `Skipped over auto-inserted ${closeChar}`,
};
}
}
13 changes: 13 additions & 0 deletions src/core/domain/grammar/ruleCatalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,19 @@ export const GRAMMAR_RULE_CATALOG: readonly GrammarRuleCatalogEntry[] = [
defaultEnabled: false,
priority: 133,
},
{
id: "autoBracketClose",
name: "Auto-close brackets and quotes",
titleI18nKey: "grammar_rule_auto_bracket_close",
descriptionI18nKey: "grammar_rule_auto_bracket_close_desc",
exampleI18nKey: "grammar_rule_auto_bracket_close_example",
languageScope: "all",
safetyTier: "advanced",
defaultRollout: "off",
recommended: false,
defaultEnabled: false,
priority: 134,
},
] as const;

export type CatalogRuleId = (typeof GRAMMAR_RULE_CATALOG)[number]["id"];
Expand Down
2 changes: 2 additions & 0 deletions src/core/domain/grammar/ruleFactory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { EnglishYourWelcomeCorrectionRule } from "./implementations/EnglishYourW
import { EnglishTheirThereBeVerbRule } from "./implementations/EnglishTheirThereBeVerbRule";
import { EnglishAlotCorrectionRule } from "./implementations/EnglishAlotCorrectionRule";
import { EnglishPronounVerbWhitelistAgreementRule } from "./implementations/EnglishPronounVerbWhitelistAgreementRule";
import { AutoBracketCloseRule } from "./implementations/AutoBracketCloseRule";

export function createGrammarRuleCatalogRuntime(options: {
insertSpaceAfterAutocomplete: boolean;
Expand Down Expand Up @@ -66,6 +67,7 @@ export function createGrammarRuleCatalogRuntime(options: {
emdashShortcut: new EmdashShortcutRule(),
smartQuoteNormalization: new SmartQuoteNormalizationRule(),
duplicatePunctuationCollapse: new DuplicatePunctuationCollapseRule(),
autoBracketClose: new AutoBracketCloseRule(),
};

return GRAMMAR_RULE_CATALOG.slice()
Expand Down
2 changes: 2 additions & 0 deletions src/core/domain/grammar/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export type GrammarRuleId =
| "emdashShortcut"
| "smartQuoteNormalization"
| "duplicatePunctuationCollapse"
| "autoBracketClose"
// Legacy ids kept for compatibility and migration handling.
| "spacingRule"
| "capitalizeFirstLetter";
Expand All @@ -46,6 +47,7 @@ export interface GrammarEdit {
replacement: string;
deleteBackwards: number; // Number of characters to delete before the cursor
deleteForwards: number; // Number of characters to delete after the cursor
cursorOffset?: number; // If set, cursor is placed at replaceStart + cursorOffset instead of end of replacement
confidence?: "high" | "medium";
sourceRuleId?: Exclude<GrammarRuleId, "spacingRule" | "capitalizeFirstLetter">;
safetyTier?: "safe" | "advanced";
Expand Down
Loading
Loading