Skip to content

Commit 7b66d78

Browse files
feat: Add support for Google Docs and enhance editable element detection
1 parent 49180b3 commit 7b66d78

2 files changed

Lines changed: 216 additions & 21 deletions

File tree

src/entrypoints/content.ts

Lines changed: 213 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ export default defineContentScript({
7070
childList: true,
7171
subtree: true,
7272
attributes: true,
73-
attributeFilter: ["contenteditable", "role"],
73+
attributeFilter: ["contenteditable", "role", "g_editable"],
7474
});
7575
}
7676

@@ -88,7 +88,7 @@ export default defineContentScript({
8888

8989
private scanExistingElements() {
9090
const textareas = document.querySelectorAll(
91-
'textarea, input, [contenteditable="true"], [role="textbox"]'
91+
'textarea, input, [contenteditable="true"], [role="textbox"], [g_editable="true"]'
9292
);
9393
textareas.forEach((el) => {
9494
if (el instanceof HTMLElement && isEditableElement(el)) {
@@ -124,7 +124,7 @@ export default defineContentScript({
124124

125125
// Check children
126126
const editables = element.querySelectorAll(
127-
'textarea, input, [contenteditable="true"], [role="textbox"]'
127+
'textarea, input, [contenteditable="true"], [role="textbox"], [g_editable="true"]'
128128
);
129129
editables.forEach((el) => {
130130
if (
@@ -167,6 +167,193 @@ export default defineContentScript({
167167
const textareaObserver = new TextareaObserver();
168168
textareaObserver.start();
169169

170+
// Google Docs Handler - special support for Google Docs canvas-based editor
171+
class GoogleDocsHandler {
172+
private observer: MutationObserver | null = null;
173+
private checkDebounceTimer: ReturnType<typeof setTimeout> | null = null;
174+
private isActive = false;
175+
private lastText = "";
176+
177+
isGoogleDocs(): boolean {
178+
return (
179+
window.location.hostname === "docs.google.com" &&
180+
window.location.pathname.includes("/document/")
181+
);
182+
}
183+
184+
getDocMode(): "canvas" | "legacy" | "unknown" {
185+
if (document.querySelector(".kix-canvas-tile-content svg")) {
186+
return "canvas";
187+
} else if (document.querySelector(".kix-paragraphrenderer")) {
188+
return "legacy";
189+
}
190+
return "unknown";
191+
}
192+
193+
extractText(): string {
194+
const mode = this.getDocMode();
195+
196+
if (mode === "canvas") {
197+
return this.extractTextFromCanvas();
198+
} else if (mode === "legacy") {
199+
return this.extractTextFromDOM();
200+
}
201+
return "";
202+
}
203+
204+
private extractTextFromCanvas(): string {
205+
const paragraphs: string[] = [];
206+
const svgGroups = document.querySelectorAll(
207+
".kix-canvas-tile-content svg > g[role=paragraph]"
208+
);
209+
210+
svgGroups.forEach((group) => {
211+
const rects = group.querySelectorAll("rect[aria-label]");
212+
let prevText = "";
213+
const words: string[] = [];
214+
215+
rects.forEach((rect) => {
216+
const text = rect.getAttribute("aria-label");
217+
if (text && text !== prevText) {
218+
words.push(text);
219+
prevText = text;
220+
}
221+
});
222+
223+
if (words.length > 0) {
224+
paragraphs.push(words.join(""));
225+
}
226+
});
227+
228+
return paragraphs.join("\n");
229+
}
230+
231+
private extractTextFromDOM(): string {
232+
const paragraphs: string[] = [];
233+
const paraElements = document.querySelectorAll(".kix-paragraphrenderer");
234+
235+
paraElements.forEach((para) => {
236+
const lines = para.querySelectorAll(".kix-lineview");
237+
const lineTexts: string[] = [];
238+
239+
lines.forEach((line) => {
240+
const words = line.querySelectorAll(
241+
".kix-wordhtmlgenerator-word-node"
242+
);
243+
let lineText = "";
244+
245+
words.forEach((word) => {
246+
let text = word.textContent || "";
247+
text = text.replace(/[\u200B\u200C]/g, "").replace(/\u00A0/g, " ");
248+
lineText += text;
249+
});
250+
251+
if (lineText) {
252+
lineTexts.push(lineText);
253+
}
254+
});
255+
256+
if (lineTexts.length > 0) {
257+
paragraphs.push(lineTexts.join(" "));
258+
}
259+
});
260+
261+
return paragraphs.join("\n");
262+
}
263+
264+
getEditorElement(): HTMLElement | null {
265+
return document.querySelector(".kix-appview-editor");
266+
}
267+
268+
start() {
269+
if (!this.isGoogleDocs() || this.isActive) return;
270+
271+
const editor = this.getEditorElement();
272+
if (!editor) {
273+
setTimeout(() => this.start(), 1000);
274+
return;
275+
}
276+
277+
this.isActive = true;
278+
activeElement = editor;
279+
280+
this.observer = new MutationObserver(() => {
281+
this.scheduleCheck();
282+
});
283+
284+
this.observer.observe(editor, {
285+
childList: true,
286+
subtree: true,
287+
characterData: true,
288+
});
289+
290+
if (settings.checkMode === "realtime") {
291+
this.scheduleCheck();
292+
}
293+
}
294+
295+
private scheduleCheck() {
296+
if (!settings.enabled || settings.checkMode !== "realtime") return;
297+
298+
if (this.checkDebounceTimer) {
299+
clearTimeout(this.checkDebounceTimer);
300+
}
301+
302+
this.checkDebounceTimer = setTimeout(() => {
303+
this.performCheck();
304+
}, settings.realtimeDelay);
305+
}
306+
307+
private async performCheck() {
308+
if (isChecking) return;
309+
310+
const text = this.extractText();
311+
if (text.length < 10 || text === this.lastText) return;
312+
313+
this.lastText = text;
314+
isChecking = true;
315+
showStatusButton("loading");
316+
317+
try {
318+
const result = await checkGrammarRequest(text);
319+
if (result) {
320+
currentSuggestions = result.suggestions;
321+
if (currentSuggestions.length > 0) {
322+
showStatusButton("errors", currentSuggestions.length);
323+
} else {
324+
showStatusButton("clean");
325+
}
326+
}
327+
} catch (error) {
328+
console.error("Google Docs grammar check error:", error);
329+
hideStatusButton();
330+
} finally {
331+
isChecking = false;
332+
}
333+
}
334+
335+
triggerManualCheck() {
336+
if (!this.isActive) return;
337+
this.performCheck();
338+
}
339+
340+
stop() {
341+
if (this.checkDebounceTimer) {
342+
clearTimeout(this.checkDebounceTimer);
343+
this.checkDebounceTimer = null;
344+
}
345+
if (this.observer) {
346+
this.observer.disconnect();
347+
this.observer = null;
348+
}
349+
this.isActive = false;
350+
this.lastText = "";
351+
}
352+
}
353+
354+
const googleDocsHandler = new GoogleDocsHandler();
355+
googleDocsHandler.start();
356+
170357
// Styles for Shadow DOM
171358
const STYLES = `
172359
* {
@@ -1224,25 +1411,29 @@ export default defineContentScript({
12241411

12251412
// Message listener for keyboard shortcut
12261413
browser.runtime.onMessage.addListener((message) => {
1227-
if (message.type === "TRIGGER_CHECK" && activeElement) {
1228-
const text = getTextFromElement(activeElement);
1229-
if (text.length > 3) {
1230-
showStatusButton("loading");
1231-
checkGrammarRequest(text, true)
1232-
.then((result) => {
1233-
if (result) {
1234-
currentSuggestions = result.suggestions;
1235-
renderUnderlines();
1236-
if (currentSuggestions.length > 0) {
1237-
showStatusButton("errors", currentSuggestions.length);
1238-
} else {
1239-
showStatusButton("clean");
1414+
if (message.type === "TRIGGER_CHECK") {
1415+
if (googleDocsHandler.isGoogleDocs()) {
1416+
googleDocsHandler.triggerManualCheck();
1417+
} else if (activeElement) {
1418+
const text = getTextFromElement(activeElement);
1419+
if (text.length > 3) {
1420+
showStatusButton("loading");
1421+
checkGrammarRequest(text, true)
1422+
.then((result) => {
1423+
if (result) {
1424+
currentSuggestions = result.suggestions;
1425+
renderUnderlines();
1426+
if (currentSuggestions.length > 0) {
1427+
showStatusButton("errors", currentSuggestions.length);
1428+
} else {
1429+
showStatusButton("clean");
1430+
}
12401431
}
1241-
}
1242-
})
1243-
.catch(() => {
1244-
hideStatusButton();
1245-
});
1432+
})
1433+
.catch(() => {
1434+
hideStatusButton();
1435+
});
1436+
}
12461437
}
12471438
}
12481439
});
@@ -1301,6 +1492,7 @@ export default defineContentScript({
13011492

13021493
ctx.onInvalidated(() => {
13031494
textareaObserver.stop();
1495+
googleDocsHandler.stop();
13041496
cleanup();
13051497
if (unwatchSettings) unwatchSettings();
13061498
});

src/lib/utils.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,9 @@ export function isEditableElement(element: Element): boolean {
6868
// Check for common rich text editor patterns
6969
if (element.getAttribute('role') === 'textbox') return true;
7070

71+
// Gmail-specific editable attribute
72+
if (element.getAttribute('g_editable') === 'true') return true;
73+
7174
return false;
7275
}
7376

0 commit comments

Comments
 (0)