Extension-style floating LanguageTool checker for Google Docs and normal text fields.
// ==UserScript==
// @name CogTech Language Tool (CogWrite)
// @namespace https://greasyfork.org/scripts/languagetool-checker
// @version 1.2.0
// @description Extension-style floating LanguageTool checker for Google Docs and normal text fields.
// @author Phil 🥹👍 and CogTech
// @match *://*/*
// @all-frames true
// @grant GM_xmlhttpRequest
// @grant GM.xmlHttpRequest
// @grant GM_addStyle
// @grant GM.addStyle
// @grant GM_getValue
// @grant GM.getValue
// @grant GM_setValue
// @grant GM.setValue
// @grant GM_registerMenuCommand
// @grant GM.registerMenuCommand
// @connect api.languagetool.org
// @run-at document-idle
// @license MIT
// ==/UserScript==
(function () {
'use strict';
if (window.__cogwriteFloatingCheckerLoaded) return;
window.__cogwriteFloatingCheckerLoaded = true;
const API_URL = 'https://api.languagetool.org/v2/check';
const MAX_TEXT_CHARS = 20000;
const EDITABLE_SELECTOR = [
'textarea',
'input[type="text"]',
'input[type="search"]',
'input[type="email"]',
'input[type="url"]',
'input[type="tel"]',
'[contenteditable="true"]',
'[contenteditable="plaintext-only"]',
'[role="textbox"]'
].join(',');
const gm = {
getValue(key, fallback) {
try {
if (typeof GM_getValue === 'function') return GM_getValue(key, fallback);
if (typeof GM !== 'undefined' && typeof GM.getValue === 'function') return GM.getValue(key, fallback);
} catch (_) {}
return fallback;
},
setValue(key, value) {
try {
if (typeof GM_setValue === 'function') return GM_setValue(key, value);
if (typeof GM !== 'undefined' && typeof GM.setValue === 'function') return GM.setValue(key, value);
} catch (_) {}
return undefined;
},
addStyle(css) {
try {
if (typeof GM_addStyle === 'function') return GM_addStyle(css);
if (typeof GM !== 'undefined' && typeof GM.addStyle === 'function') return GM.addStyle(css);
} catch (_) {}
const style = document.createElement('style');
style.textContent = css;
(document.head || document.documentElement).appendChild(style);
return style;
},
registerMenuCommand(name, fn) {
try {
if (typeof GM_registerMenuCommand === 'function') return GM_registerMenuCommand(name, fn);
if (typeof GM !== 'undefined' && typeof GM.registerMenuCommand === 'function') return GM.registerMenuCommand(name, fn);
} catch (_) {}
return undefined;
},
request(details) {
if (typeof GM_xmlhttpRequest === 'function') return GM_xmlhttpRequest(details);
if (typeof GM !== 'undefined' && typeof GM.xmlHttpRequest === 'function') return GM.xmlHttpRequest(details);
return fetch(details.url, {
method: details.method || 'GET',
headers: details.headers || {},
body: details.data
}).then(async (res) => {
details.onload?.({ status: res.status, responseText: await res.text() });
}).catch((err) => details.onerror?.(err));
}
};
const settings = {
enabled: gm.getValue('lt_enabled', true),
language: gm.getValue('lt_language', 'auto'),
preferredVariants: gm.getValue('lt_variants', 'en-US,en-GB,de-DE'),
picky: gm.getValue('lt_picky', false)
};
let root;
let panel;
let button;
let statusEl;
let textBox;
let resultsEl;
let lastMatches = [];
gm.addStyle(`
#cogwrite-root {
position: fixed !important;
top: 86px !important;
right: 18px !important;
z-index: 2147483647 !important;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif !important;
color: #f8fafc !important;
}
#cogwrite-root button,
#cogwrite-root textarea {
font: inherit !important;
}
#cogwrite-button {
width: 48px !important;
height: 48px !important;
border-radius: 50% !important;
border: 1px solid #334155 !important;
background: #111827 !important;
color: #ffffff !important;
box-shadow: 0 10px 28px rgba(15, 23, 42, 0.32) !important;
cursor: pointer !important;
font-weight: 700 !important;
font-size: 14px !important;
display: flex !important;
align-items: center !important;
justify-content: center !important;
user-select: none !important;
}
#cogwrite-button.cogwrite-has-issues {
background: #991b1b !important;
border-color: #fecaca !important;
}
#cogwrite-panel {
display: none !important;
position: absolute !important;
right: 0 !important;
top: 58px !important;
width: 360px !important;
max-width: calc(100vw - 36px) !important;
max-height: 520px !important;
overflow: hidden !important;
border: 1px solid #334155 !important;
border-radius: 8px !important;
background: #0f172a !important;
box-shadow: 0 18px 48px rgba(15, 23, 42, 0.38) !important;
}
#cogwrite-panel.cogwrite-open {
display: block !important;
}
.cogwrite-header {
display: flex !important;
align-items: center !important;
justify-content: space-between !important;
padding: 10px 12px !important;
border-bottom: 1px solid #334155 !important;
font-weight: 700 !important;
}
.cogwrite-header button {
background: transparent !important;
border: 0 !important;
color: #cbd5e1 !important;
cursor: pointer !important;
font-size: 16px !important;
padding: 2px 6px !important;
}
.cogwrite-body {
padding: 10px !important;
}
#cogwrite-status {
color: #cbd5e1 !important;
font-size: 12px !important;
line-height: 1.4 !important;
margin-bottom: 8px !important;
}
.cogwrite-actions {
display: flex !important;
gap: 8px !important;
margin-bottom: 10px !important;
}
.cogwrite-action {
border: 0 !important;
border-radius: 6px !important;
background: #2563eb !important;
color: #ffffff !important;
cursor: pointer !important;
padding: 7px 10px !important;
font-size: 12px !important;
white-space: nowrap !important;
}
.cogwrite-action.secondary {
background: #334155 !important;
}
#cogwrite-text {
width: 100% !important;
min-height: 92px !important;
box-sizing: border-box !important;
resize: vertical !important;
border-radius: 6px !important;
border: 1px solid #475569 !important;
background: #020617 !important;
color: #f8fafc !important;
padding: 8px !important;
margin-bottom: 10px !important;
outline: none !important;
}
#cogwrite-results {
max-height: 260px !important;
overflow-y: auto !important;
}
.cogwrite-issue {
border-top: 1px solid #334155 !important;
padding: 9px 0 !important;
}
.cogwrite-message {
font-weight: 700 !important;
margin-bottom: 5px !important;
}
.cogwrite-context {
color: #cbd5e1 !important;
overflow-wrap: anywhere !important;
margin-bottom: 7px !important;
}
.cogwrite-suggestions {
display: flex !important;
flex-wrap: wrap !important;
gap: 6px !important;
}
.cogwrite-suggestion {
border: 0 !important;
border-radius: 6px !important;
background: #334155 !important;
color: #ffffff !important;
cursor: pointer !important;
padding: 4px 7px !important;
font-size: 12px !important;
}
`);
function isEditable(el) {
return Boolean(
el &&
el.nodeType === Node.ELEMENT_NODE &&
el.matches?.(EDITABLE_SELECTOR) &&
!el.disabled &&
!el.readOnly
);
}
function getEditableText(el) {
if (!isEditable(el)) return '';
if (el instanceof HTMLTextAreaElement || el instanceof HTMLInputElement) return el.value || '';
return el.innerText || el.textContent || '';
}
function getSelectedOrFocusedText() {
const selection = String(window.getSelection?.() || '').trim();
if (selection) return selection;
const active = document.activeElement;
const focusedText = getEditableText(active).trim();
if (focusedText) return focusedText;
return '';
}
function setStatus(message) {
statusEl.textContent = message;
}
function setOpen(open) {
panel.classList.toggle('cogwrite-open', open);
}
function setButtonState(matches, checking = false) {
button.classList.toggle('cogwrite-has-issues', matches.length > 0);
button.textContent = checking ? '...' : matches.length ? String(matches.length) : 'CW';
}
function checkText(text) {
const value = String(text || '').trim();
if (!value) {
lastMatches = [];
setButtonState(lastMatches);
setStatus('Paste text here, select text in the page, or click into a normal text field.');
renderResults('', []);
return;
}
const body = new URLSearchParams();
body.set('text', value.slice(0, MAX_TEXT_CHARS));
body.set('language', settings.language);
if (settings.language === 'auto') body.set('preferredVariants', settings.preferredVariants);
if (settings.picky) body.set('level', 'picky');
setStatus('Checking with LanguageTool...');
setButtonState(lastMatches, true);
gm.request({
method: 'POST',
url: API_URL,
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
data: body.toString(),
onload(res) {
if (res.status !== 200) {
setStatus(`LanguageTool API error ${res.status}`);
setButtonState(lastMatches);
return;
}
try {
const data = JSON.parse(res.responseText);
lastMatches = data.matches || [];
setStatus(`${lastMatches.length} issue${lastMatches.length === 1 ? '' : 's'} found.`);
renderResults(value, lastMatches);
setButtonState(lastMatches);
} catch (error) {
setStatus(error.message);
setButtonState(lastMatches);
}
},
onerror() {
setStatus('Network error contacting LanguageTool.');
setButtonState(lastMatches);
}
});
}
function renderResults(text, matches) {
resultsEl.textContent = '';
if (!matches.length) {
const empty = document.createElement('div');
empty.className = 'cogwrite-empty';
empty.textContent = text ? 'No issues found.' : 'No text checked yet.';
resultsEl.appendChild(empty);
return;
}
for (const match of matches) {
const issue = document.createElement('div');
issue.className = 'cogwrite-issue';
const ctxStart = Math.max(0, match.offset - 24);
const ctxEnd = Math.min(text.length, match.offset + match.length + 24);
const message = document.createElement('div');
message.className = 'cogwrite-message';
message.textContent = match.message;
issue.appendChild(message);
const context = document.createElement('div');
context.className = 'cogwrite-context';
context.appendChild(document.createTextNode(text.slice(ctxStart, match.offset)));
const badText = document.createElement('b');
badText.textContent = text.slice(match.offset, match.offset + match.length);
context.appendChild(badText);
context.appendChild(document.createTextNode(text.slice(match.offset + match.length, ctxEnd)));
issue.appendChild(context);
const suggestions = document.createElement('div');
suggestions.className = 'cogwrite-suggestions';
for (const replacement of (match.replacements || []).slice(0, 5)) {
const suggestion = document.createElement('button');
suggestion.type = 'button';
suggestion.className = 'cogwrite-suggestion';
suggestion.textContent = replacement.value;
suggestion.addEventListener('click', () => {
textBox.value = textBox.value.slice(0, match.offset) +
replacement.value +
textBox.value.slice(match.offset + match.length);
checkText(textBox.value);
});
suggestions.appendChild(suggestion);
}
issue.appendChild(suggestions);
resultsEl.appendChild(issue);
}
}
function createUi() {
if (!settings.enabled || root || !document.documentElement) return;
root = document.createElement('div');
root.id = 'cogwrite-root';
panel = document.createElement('div');
panel.id = 'cogwrite-panel';
const header = document.createElement('div');
header.className = 'cogwrite-header';
const title = document.createElement('span');
title.textContent = 'CogWrite';
const closeButton = document.createElement('button');
closeButton.type = 'button';
closeButton.setAttribute('aria-label', 'Close');
closeButton.textContent = 'x';
header.append(title, closeButton);
const body = document.createElement('div');
body.className = 'cogwrite-body';
statusEl = document.createElement('div');
statusEl.id = 'cogwrite-status';
statusEl.textContent = 'Paste text here or select text in the document.';
const actions = document.createElement('div');
actions.className = 'cogwrite-actions';
const grabButton = document.createElement('button');
grabButton.type = 'button';
grabButton.className = 'cogwrite-action';
grabButton.dataset.action = 'grab';
grabButton.textContent = 'Use selected text';
const checkButton = document.createElement('button');
checkButton.type = 'button';
checkButton.className = 'cogwrite-action';
checkButton.dataset.action = 'check';
checkButton.textContent = 'Check';
const clearButton = document.createElement('button');
clearButton.type = 'button';
clearButton.className = 'cogwrite-action secondary';
clearButton.dataset.action = 'clear';
clearButton.textContent = 'Clear';
actions.append(grabButton, checkButton, clearButton);
textBox = document.createElement('textarea');
textBox.id = 'cogwrite-text';
textBox.placeholder = 'Paste or type text to check';
resultsEl = document.createElement('div');
resultsEl.id = 'cogwrite-results';
body.append(statusEl, actions, textBox, resultsEl);
panel.append(header, body);
button = document.createElement('button');
button.type = 'button';
button.id = 'cogwrite-button';
button.title = 'CogWrite';
button.textContent = 'CW';
root.append(panel, button);
document.documentElement.appendChild(root);
button.addEventListener('click', () => setOpen(!panel.classList.contains('cogwrite-open')));
closeButton.addEventListener('click', () => setOpen(false));
grabButton.addEventListener('click', () => {
const text = getSelectedOrFocusedText();
textBox.value = text;
setStatus(text ? 'Loaded text. Press Check.' : 'No selectable or focused text found. Paste text below.');
});
checkButton.addEventListener('click', () => checkText(textBox.value));
clearButton.addEventListener('click', () => {
textBox.value = '';
lastMatches = [];
setButtonState(lastMatches);
setStatus('Cleared.');
renderResults('', []);
});
renderResults('', []);
}
gm.registerMenuCommand(settings.enabled ? 'Disable CogWrite' : 'Enable CogWrite', () => {
settings.enabled = !settings.enabled;
gm.setValue('lt_enabled', settings.enabled);
location.reload();
});
gm.registerMenuCommand('CogWrite language: auto', () => {
settings.language = 'auto';
gm.setValue('lt_language', 'auto');
});
gm.registerMenuCommand('CogWrite language: en-US', () => {
settings.language = 'en-US';
gm.setValue('lt_language', 'en-US');
});
gm.registerMenuCommand('Toggle CogWrite picky mode', () => {
settings.picky = !settings.picky;
gm.setValue('lt_picky', settings.picky);
alert(`Picky mode: ${settings.picky ? 'on' : 'off'}`);
});
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', createUi, { once: true });
} else {
createUi();
}
})();