@@ -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 } ) ;
0 commit comments