Mohamed Atef commited on
Commit
f8feb00
·
1 Parent(s): 74411f9

Change 1 — Write-back to the page field

Browse files

Change 2 — Context menu items
Change 3 — Suppress re-analysis after non-correction write-back

extension/PLAN_apply_contextmenu_reanalysis.md ADDED
@@ -0,0 +1,262 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # BAYAN Extension — Implementation Plan: Apply-to-Field, Context Menu Features & Smart Re-Analysis
2
+
3
+ > **For:** the coding agent implementing these changes.
4
+ > **Scope:** Chrome extension only (`extension/`). No backend changes.
5
+ > **Read this whole file before editing.** Each change lists the exact files, the current behavior, the target behavior, and the wiring required. Implement the three changes in order — Change 1 builds the messaging channel that Change 3 reuses.
6
+
7
+ ---
8
+
9
+ ## Background: how the pieces currently connect
10
+
11
+ - **`content-inline.js`** — injected into every page. Detects editable fields (`textarea`, `input`, `contenteditable`), analyzes them, and renders an overlay + tooltip. It **already writes corrections back** to the page field via `applyFix()`. It currently has **no `chrome.runtime.onMessage` listener** (it only *sends* messages).
12
+ - **`background.js`** — service worker. Owns the context menu and the API bridge. Registers exactly two menu items: `bayan-correct`, `bayan-summarize`. On click it opens the side panel and stashes `{contextAction, contextText}` in `chrome.storage.session`.
13
+ - **`sidepanel/sidepanel.js`** — reads that stashed context on open, switches to the matching tab, and auto-runs. It has `correct`, `summarize`, `dialect`, `quran`, `autocomplete` tabs and handlers. Its apply / apply-all buttons currently **only edit the side panel's own textarea** — they do **not** touch the page field.
14
+ - **`popup.js`** — same apply logic as the side panel, also only editing its own textarea.
15
+
16
+ Key gap for Changes 1 & 3: **the panel surfaces have no link back to the page field the text came from.** We must establish that link.
17
+
18
+ ---
19
+
20
+ ## CHANGE 1 — "Apply" / "Apply all" writes the result into the user's actual text field
21
+
22
+ ### Current behavior
23
+ - **Inline tooltip apply** (`content-inline.js` → `applyFix`): ✅ already writes back to the page field and dispatches an `input` event. No change needed here.
24
+ - **Side panel & popup apply / apply-all**: ❌ only mutate the panel's own `<textarea id="input-text">`. The user's text field on the page is untouched.
25
+
26
+ ### Target behavior
27
+ When the user clicks **Apply** (single suggestion) or **Apply all** in the **side panel** (and popup where applicable), the corrected text is written into the **original page field** that the text came from — replacing the selection, or the whole field if appropriate — and an `input` event is dispatched so the host page registers the change.
28
+
29
+ ### Why this needs a messaging channel
30
+ The side panel is a separate document; it cannot touch the page DOM directly. It must send a message → background → content script → content script writes into the field.
31
+
32
+ ### Implementation
33
+
34
+ **1.1 — `content-inline.js`: track the "source field" and expose a write-back handler**
35
+
36
+ - Add module state: `let lastInteractedField = null;`
37
+ - In `attachField(field)` (or `focusin`), set `lastInteractedField = field;` whenever a real editable field is focused. (Keep `activeField` semantics as-is; `lastInteractedField` persists even after focus moves to the side panel, which `activeField`/`detachField` would clear.)
38
+ - When the FAB sends `OPEN_SIDEPANEL` (existing code ~line 548), also include a stable identifier so we can re-find the field. Simplest robust approach: **tag the field** with a data attribute when opening the panel:
39
+ ```js
40
+ // before sending OPEN_SIDEPANEL
41
+ if (lastInteractedField) lastInteractedField.dataset.bayanSource = '1';
42
+ ```
43
+ - Add a **`chrome.runtime.onMessage` listener** (the content script currently has none):
44
+ ```js
45
+ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
46
+ if (msg.type === 'BAYAN_WRITE_BACK') {
47
+ const field = lastInteractedField
48
+ || document.querySelector('[data-bayan-source="1"]')
49
+ || (isEditableField(document.activeElement) ? document.activeElement : null);
50
+ if (!field) { sendResponse({ ok: false, reason: 'no_field' }); return true; }
51
+ writeTextToField(field, msg.text, msg.mode); // mode: 'replaceAll' | 'replaceSelection'
52
+ sendResponse({ ok: true });
53
+ return true;
54
+ }
55
+ return false;
56
+ });
57
+ ```
58
+ - Implement `writeTextToField(field, text, mode)`:
59
+ - For `textarea`/`input`: if `mode === 'replaceSelection'` and `selectionStart !== selectionEnd`, splice into `field.value` at the selection; else set `field.value = text`. Then `field.setSelectionRange(end, end)` and dispatch `new Event('input', { bubbles: true })`.
60
+ - For `contenteditable`: focus the field and use `document.execCommand('insertText', false, text)` when there's a live selection inside it; otherwise set `field.textContent = text` and dispatch `input`. (Mirror the overlay-safe approach already used in `applyFix`.)
61
+ - **Set the suppression flag from Change 3** right before dispatching `input` (see Change 3) when the write came from a non-correction model. For Change 1's correction apply-back, do **not** suppress — corrected text re-analyzing is harmless/expected.
62
+ - Clear `data-bayan-source` after writing.
63
+
64
+ **1.2 — `background.js`: relay panel → content script**
65
+
66
+ - Add a message handler branch:
67
+ ```js
68
+ if (message.type === 'WRITE_BACK_TO_PAGE') {
69
+ // forward to the active tab's content script
70
+ chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
71
+ const tab = tabs[0];
72
+ if (!tab) { sendResponse({ ok: false }); return; }
73
+ chrome.tabs.sendMessage(tab.id, {
74
+ type: 'BAYAN_WRITE_BACK', text: message.text, mode: message.mode || 'replaceAll', source: message.source
75
+ }, (resp) => sendResponse(resp || { ok: false }));
76
+ });
77
+ return true; // async
78
+ }
79
+ ```
80
+
81
+ **1.3 — `sidepanel/sidepanel.js`: send write-back after apply / apply-all**
82
+
83
+ - Add a helper:
84
+ ```js
85
+ function writeBackToPage(text, mode = 'replaceAll', source = 'correct') {
86
+ chrome.runtime.sendMessage(
87
+ { type: 'WRITE_BACK_TO_PAGE', text, mode, source },
88
+ (resp) => {
89
+ if (resp && resp.ok) showToast('✓ تم تطبيق التغييرات في الصفحة');
90
+ else showToast('تعذّر الكتابة في الصفحة — انسخ النص يدوياً');
91
+ }
92
+ );
93
+ }
94
+ ```
95
+ - In the existing **apply-all** handler (after `analyzedText = applyAllPatches(...)`), call `writeBackToPage(analyzedText, 'replaceAll', 'correct');`.
96
+ - In the single-suggestion **apply** path (after `applyAndRebase` updates `analyzedText`), call `writeBackToPage(analyzedText, 'replaceAll', 'correct');`. (Whole-field replace is simplest and avoids offset drift between the panel copy and the live field.)
97
+
98
+ **1.4 — `popup.js`** (optional, lower priority)
99
+ - The popup closes when it loses focus, so write-back is less reliable there. Apply the same `writeBackToPage` helper **only if** product wants it; otherwise leave the popup apply editing its own textarea and rely on copy/download. Document the decision in a comment.
100
+
101
+ ### Acceptance criteria
102
+ - Select/focus a page `<textarea>`, open the side panel via the FAB, click **Apply all** → the page textarea content is replaced with the corrected text and the host page sees an `input` event.
103
+ - Works for a single **Apply** too.
104
+ - If the source field can't be found, the user gets a clear toast (no silent failure).
105
+
106
+ ---
107
+
108
+ ## CHANGE 2 — Add "لهجات" and "قرآن" to the right-click context menu
109
+
110
+ ### Current behavior
111
+ `background.js` registers only:
112
+ - `bayan-correct` → "تصحيح مع بيان"
113
+ - `bayan-summarize` → "تلخيص مع بيان"
114
+
115
+ ### Target behavior
116
+ The selection context menu shows **four** Bayan items:
117
+ - تصحيح مع بيان (existing)
118
+ - تلخيص مع بيان (existing)
119
+ - **تحويل اللهجة إلى الفصحى مع بيان** (new)
120
+ - **تدقيق الآية مع بيان** (new)
121
+
122
+ Clicking a new item opens the side panel, switches to the matching tab, fills the selected text, and auto-runs that model.
123
+
124
+ ### Implementation
125
+
126
+ **2.1 — `background.js`**
127
+ - Extend the actions map:
128
+ ```js
129
+ const ACTIONS = { CORRECT: 'correct', SUMMARIZE: 'summarize', DIALECT: 'dialect', QURAN: 'quran' };
130
+ ```
131
+ - In `chrome.runtime.onInstalled` add two `chrome.contextMenus.create(...)` calls:
132
+ ```js
133
+ chrome.contextMenus.create({ id: 'bayan-dialect',
134
+ title: chrome.i18n.getMessage('contextMenuDialect') || 'تحويل اللهجة إلى الفصحى مع بيان',
135
+ contexts: ['selection'] });
136
+ chrome.contextMenus.create({ id: 'bayan-quran',
137
+ title: chrome.i18n.getMessage('contextMenuQuran') || 'تدقيق الآية مع بيان',
138
+ contexts: ['selection'] });
139
+ ```
140
+ - In `chrome.contextMenus.onClicked`, add routing:
141
+ ```js
142
+ if (info.menuItemId === 'bayan-dialect') action = ACTIONS.DIALECT;
143
+ if (info.menuItemId === 'bayan-quran') action = ACTIONS.QURAN;
144
+ ```
145
+ The rest of the handler (open side panel + stash context) already works generically.
146
+
147
+ **2.2 — `sidepanel/sidepanel.js`** — handle the two new context actions on pickup
148
+ - The side panel already reads `contextAction` and, for `correct`/`summarize`, switches tab + auto-runs. Extend **both** pickup paths (`tryPickupContext` AND the `storage.onChanged` listener) to handle the new actions:
149
+ ```js
150
+ } else if (action === 'dialect') {
151
+ dialectInput.value = text; updateCounts(dialectInput, dialectCharCount, null);
152
+ document.querySelector('[data-tab="dialect"]')?.click();
153
+ setTimeout(() => btnDialect.click(), 120);
154
+ } else if (action === 'quran') {
155
+ quranInput.value = text; updateCounts(quranInput, quranCharCount, null);
156
+ document.querySelector('[data-tab="quran"]')?.click();
157
+ setTimeout(() => btnQuran.click(), 120);
158
+ }
159
+ ```
160
+ (Hoist `dialectInput`, `btnDialect`, `quranInput`, `btnQuran`, etc. so they're in scope of the pickup functions, or wrap the pickup dispatch in a small `runContextAction(action, text)` function declared after all element refs.)
161
+ - Update the `TAB` constant if it's used for validation: `const TAB = { CORRECT:'correct', SUMMARIZE:'summarize', DIALECT:'dialect', QURAN:'quran' };`
162
+
163
+ **2.3 — Locales** — add the two new menu strings
164
+ - `_locales/ar/messages.json`:
165
+ ```json
166
+ "contextMenuDialect": { "message": "تحويل اللهجة إلى الفصحى مع بيان", "description": "Context menu: dialect→MSA" },
167
+ "contextMenuQuran": { "message": "تدقيق الآية مع بيان", "description": "Context menu: Quran verify" }
168
+ ```
169
+ - `_locales/en/messages.json`:
170
+ ```json
171
+ "contextMenuDialect": { "message": "Convert dialect to MSA with Bayan", "description": "Context menu: dialect→MSA" },
172
+ "contextMenuQuran": { "message": "Verify verse with Bayan", "description": "Context menu: Quran verify" }
173
+ ```
174
+
175
+ ### Acceptance criteria
176
+ - Right-clicking selected Arabic text shows all four Bayan items.
177
+ - "تحويل اللهجة…" opens the side panel on the **لهجة** tab with the text filled and converted.
178
+ - "تدقيق الآية…" opens the side panel on the **قرآن** tab with the text filled and checked.
179
+ - Existing correct/summarize items are unchanged.
180
+
181
+ ---
182
+
183
+ ## CHANGE 3 — After applying summarize / dialect / quran output into a field, do NOT auto-run the correction model on it
184
+
185
+ ### The problem
186
+ `content-inline.js` analyzes editable fields on every keystroke and on programmatic `input` events. If text written back into the field came from the **summarize**, **dialect**, or **quran** model (Change 1's write-back for those flows), the correction pipeline would immediately re-analyze it — which the user does **not** want (a summary/MSA/verse is the intended final text, not something to "correct").
187
+
188
+ > Note: for the **correction** apply-back (Change 1), re-analysis is fine/expected and must stay enabled.
189
+
190
+ ### Target behavior
191
+ When a write-back originates from a **non-correction** model (`summarize` / `dialect` / `quran`), the content script must **suppress correction analysis** for that field until the **user makes a genuine manual edit** (a real keystroke), at which point normal analysis resumes.
192
+
193
+ ### Implementation (`content-inline.js`)
194
+
195
+ - Add module state:
196
+ ```js
197
+ let analysisSuppressed = false; // true after non-correction model write-back
198
+ ```
199
+ - Extend `writeTextToField(field, text, mode)` (from Change 1) to accept the `source`/`mode` info, and:
200
+ - If the write-back `source` is one of `summarize|dialect|quran`, set `analysisSuppressed = true;` **before** dispatching the `input` event.
201
+ - If the `source` is `correct` (or inline correction apply), leave `analysisSuppressed = false`.
202
+ - In `onFieldInput()` (the input handler), **gate the analysis** but distinguish programmatic vs. human input. The cleanest signal: the synthetic `input` event we dispatch is not trusted. So:
203
+ ```js
204
+ function onFieldInput(e) {
205
+ if (paused || !activeField) return;
206
+
207
+ const programmatic = e && e.isTrusted === false;
208
+ // A genuine user keystroke clears suppression and re-enables analysis.
209
+ if (!programmatic && analysisSuppressed) analysisSuppressed = false;
210
+
211
+ // Ghost-text autocomplete still runs (it's not the correction model).
212
+ scheduleGhost();
213
+
214
+ if (analysisSuppressed) { // model output just written — skip correction
215
+ clearHighlights();
216
+ updateBadge(0);
217
+ return;
218
+ }
219
+ // ...existing analysis path unchanged...
220
+ }
221
+ ```
222
+ - Ensure `onFieldInput` is registered so it receives the event object (it's added via `field.addEventListener('input', onFieldInput)` — the handler already receives `e`).
223
+ - The write-back's dispatched event uses `new Event('input', {bubbles:true})`, whose `isTrusted` is `false` — this is the reliable discriminator between "we wrote this" and "the user typed."
224
+ - Edge cases to handle:
225
+ - **Badge state:** when suppressed, show the clean/✓ badge (0), not the analyzing spinner.
226
+ - **Decide on ghost-text:** keep autocomplete ghost active (it's a separate, opt-in feature) OR also suppress it for summarize/quran outputs if product prefers a fully "frozen" result. Default: keep ghost on; add a one-line comment noting the choice.
227
+ - **Re-enable correctness:** the very next real keystroke must restore analysis. Verify by typing one character after a dialect write-back → underlines should come back.
228
+
229
+ ### Acceptance criteria
230
+ - Apply a **dialect** (or summarize/quran) result into a page field via Change 1 → the field shows the model output with **no correction underlines** and the FAB badge is not in an error/analyzing state.
231
+ - Type one character in that field → correction analysis resumes normally.
232
+ - Applying a **correction** result still re-analyzes as before (suppression must NOT trigger for `source: 'correct'`).
233
+
234
+ ---
235
+
236
+ ## Suggested implementation order & testing
237
+
238
+ 1. **Change 2** first (self-contained, no messaging) — fastest win, easy to verify.
239
+ 2. **Change 1** (build the messaging channel: content-script `onMessage` + `writeTextToField` + background relay + side-panel `writeBackToPage`).
240
+ 3. **Change 3** (reuses Change 1's `writeTextToField` + `source` flag).
241
+
242
+ ### Manual test pass (Chrome, `chrome://extensions` → Load unpacked → `extension/`)
243
+ - Reload the extension after editing the service worker / manifest (context menus only re-register on install/update — use the reload button).
244
+ - **Change 2:** right-click selected Arabic text → confirm 4 items → click لهجات and قرآن → correct tab opens + auto-runs.
245
+ - **Change 1:** focus a page `<textarea>`, open side panel via FAB, Apply / Apply all → page field updates + host page sees the change.
246
+ - **Change 3:** send a dialect result back to the field → no correction underlines → type one char → underlines return. Confirm a correction apply-back still re-analyzes.
247
+
248
+ ### Files touched (summary)
249
+ | File | Change 1 | Change 2 | Change 3 |
250
+ |------|:---:|:---:|:---:|
251
+ | `extension/content-inline.js` | ✅ onMessage + `writeTextToField` + `lastInteractedField` | — | ✅ `analysisSuppressed` gate |
252
+ | `extension/background.js` | ✅ `WRITE_BACK_TO_PAGE` relay | ✅ 2 menu items + routing | — |
253
+ | `extension/sidepanel/sidepanel.js` | ✅ `writeBackToPage` on apply/apply-all | ✅ pickup for dialect/quran | ✅ pass correct `source` |
254
+ | `extension/popup.js` | ⚠️ optional | — | — |
255
+ | `extension/_locales/ar/messages.json` | — | ✅ 2 strings | — |
256
+ | `extension/_locales/en/messages.json` | — | ✅ 2 strings | — |
257
+
258
+ ### Guardrails
259
+ - Don't break the existing inline `applyFix` write-back — it already works; reuse its patterns, don't replace it.
260
+ - All cross-document communication goes panel → `background.js` → content script. The side panel must never assume direct page DOM access.
261
+ - Use `event.isTrusted === false` (not a custom flag on the event) to detect programmatic input — it's tamper-proof and requires no host-page cooperation.
262
+ - Keep every new code path guarded (`if (!field) ...`, `resp || {ok:false}`) so a missing field or closed tab degrades to a toast, never an uncaught error.
extension/_locales/ar/messages.json CHANGED
@@ -14,5 +14,13 @@
14
  "contextMenuSummarize": {
15
  "message": "تلخيص مع بيان",
16
  "description": "Context menu item for summarizing selected text"
 
 
 
 
 
 
 
 
17
  }
18
  }
 
14
  "contextMenuSummarize": {
15
  "message": "تلخيص مع بيان",
16
  "description": "Context menu item for summarizing selected text"
17
+ },
18
+ "contextMenuDialect": {
19
+ "message": "تحويل اللهجة إلى الفصحى مع بيان",
20
+ "description": "Context menu: dialect→MSA"
21
+ },
22
+ "contextMenuQuran": {
23
+ "message": "تدقيق الآية مع بيان",
24
+ "description": "Context menu: Quran verify"
25
  }
26
  }
extension/_locales/en/messages.json CHANGED
@@ -14,5 +14,13 @@
14
  "contextMenuSummarize": {
15
  "message": "Summarize with Bayan",
16
  "description": "Context menu item for summarizing selected text"
 
 
 
 
 
 
 
 
17
  }
18
  }
 
14
  "contextMenuSummarize": {
15
  "message": "Summarize with Bayan",
16
  "description": "Context menu item for summarizing selected text"
17
+ },
18
+ "contextMenuDialect": {
19
+ "message": "Convert dialect to MSA with Bayan",
20
+ "description": "Context menu: dialect→MSA"
21
+ },
22
+ "contextMenuQuran": {
23
+ "message": "Verify verse with Bayan",
24
+ "description": "Context menu: Quran verify"
25
  }
26
  }
extension/background.js CHANGED
@@ -12,7 +12,7 @@
12
  importScripts('shared/constants.js', 'shared/hash.js');
13
 
14
  // ── Context constants ──
15
- const ACTIONS = { CORRECT: 'correct', SUMMARIZE: 'summarize' };
16
  const CONTEXT_KEYS = ['contextAction', 'contextText', 'contextTimestamp'];
17
  const SIDE_PANEL_PATH = 'sidepanel/sidepanel.html';
18
 
@@ -59,6 +59,16 @@ chrome.runtime.onInstalled.addListener(() => {
59
  title: chrome.i18n.getMessage('contextMenuSummarize') || 'تلخيص مع بيان',
60
  contexts: ['selection'],
61
  });
 
 
 
 
 
 
 
 
 
 
62
  });
63
 
64
  chrome.contextMenus.onClicked.addListener((info, tab) => {
@@ -68,6 +78,8 @@ chrome.contextMenus.onClicked.addListener((info, tab) => {
68
  let action = null;
69
  if (info.menuItemId === 'bayan-correct') action = ACTIONS.CORRECT;
70
  if (info.menuItemId === 'bayan-summarize') action = ACTIONS.SUMMARIZE;
 
 
71
  if (!action) return;
72
 
73
  // Open side panel IMMEDIATELY (preserve user gesture)
@@ -174,6 +186,21 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
174
  return true;
175
  }
176
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
177
  return false;
178
  });
179
 
 
12
  importScripts('shared/constants.js', 'shared/hash.js');
13
 
14
  // ── Context constants ──
15
+ const ACTIONS = { CORRECT: 'correct', SUMMARIZE: 'summarize', DIALECT: 'dialect', QURAN: 'quran' };
16
  const CONTEXT_KEYS = ['contextAction', 'contextText', 'contextTimestamp'];
17
  const SIDE_PANEL_PATH = 'sidepanel/sidepanel.html';
18
 
 
59
  title: chrome.i18n.getMessage('contextMenuSummarize') || 'تلخيص مع بيان',
60
  contexts: ['selection'],
61
  });
62
+ chrome.contextMenus.create({
63
+ id: 'bayan-dialect',
64
+ title: chrome.i18n.getMessage('contextMenuDialect') || 'تحويل اللهجة إلى الفصحى مع بيان',
65
+ contexts: ['selection'],
66
+ });
67
+ chrome.contextMenus.create({
68
+ id: 'bayan-quran',
69
+ title: chrome.i18n.getMessage('contextMenuQuran') || 'تدقيق الآية مع بيان',
70
+ contexts: ['selection'],
71
+ });
72
  });
73
 
74
  chrome.contextMenus.onClicked.addListener((info, tab) => {
 
78
  let action = null;
79
  if (info.menuItemId === 'bayan-correct') action = ACTIONS.CORRECT;
80
  if (info.menuItemId === 'bayan-summarize') action = ACTIONS.SUMMARIZE;
81
+ if (info.menuItemId === 'bayan-dialect') action = ACTIONS.DIALECT;
82
+ if (info.menuItemId === 'bayan-quran') action = ACTIONS.QURAN;
83
  if (!action) return;
84
 
85
  // Open side panel IMMEDIATELY (preserve user gesture)
 
186
  return true;
187
  }
188
 
189
+ if (message.type === 'WRITE_BACK_TO_PAGE') {
190
+ // Forward to the active tab's content script, which owns page DOM access.
191
+ chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
192
+ const tab = tabs[0];
193
+ if (!tab) { sendResponse({ ok: false }); return; }
194
+ chrome.tabs.sendMessage(tab.id, {
195
+ type: 'BAYAN_WRITE_BACK',
196
+ text: message.text,
197
+ mode: message.mode || 'replaceAll',
198
+ source: message.source,
199
+ }, (resp) => sendResponse(resp || { ok: false }));
200
+ });
201
+ return true; // async
202
+ }
203
+
204
  return false;
205
  });
206
 
extension/content-inline.js CHANGED
@@ -55,6 +55,8 @@
55
  const IS_PROTECTED = BayanController.isProtectedSite();
56
 
57
  let activeField = null;
 
 
58
  let lastAnalyzedText = '';
59
  let suggestions = [];
60
  let paused = false;
@@ -100,14 +102,30 @@
100
  // 2. ANALYSIS (delegates to BayanController)
101
  // ══════════════════════════════════════════════════════════
102
 
103
- function onFieldInput() {
104
  if (paused || !activeField) return;
105
 
 
 
 
 
 
 
 
106
  const text = getFieldText(activeField);
107
 
108
- // Ghost-text autocomplete (textarea/input only) runs independently of analysis.
 
109
  scheduleGhost();
110
 
 
 
 
 
 
 
 
 
111
  if (!BayanController.hasArabic(text)) {
112
  clearHighlights();
113
  updateBadge(0);
@@ -545,6 +563,9 @@
545
  }
546
  if (suggestions.length > 0) {
547
  try {
 
 
 
548
  chrome.runtime.sendMessage({ type: 'OPEN_SIDEPANEL', text: lastAnalyzedText });
549
  } catch {}
550
  }
@@ -598,6 +619,7 @@
598
  detachField();
599
 
600
  activeField = field;
 
601
  suggestions = [];
602
  if (paused) paused = false;
603
 
@@ -628,6 +650,85 @@
628
  if (floatingBtn) floatingBtn.classList.remove('bayan-il-fab--visible');
629
  }
630
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
631
  // ══════════════════════════════════════════════════════════
632
  // Global listeners
633
  // ══════════════════════════════════════════════════════════
 
55
  const IS_PROTECTED = BayanController.isProtectedSite();
56
 
57
  let activeField = null;
58
+ let lastInteractedField = null; // persists after focus moves to side panel (Change 1)
59
+ let analysisSuppressed = false; // true after a non-correction model write-back (Change 3)
60
  let lastAnalyzedText = '';
61
  let suggestions = [];
62
  let paused = false;
 
102
  // 2. ANALYSIS (delegates to BayanController)
103
  // ══════════════════════════════════════════════════════════
104
 
105
+ function onFieldInput(e) {
106
  if (paused || !activeField) return;
107
 
108
+ // Only a genuine user keystroke (a trusted `input` event) clears
109
+ // suppression and re-enables analysis. Our own write-back dispatches an
110
+ // untrusted event (isTrusted === false), and internal re-runs pass no
111
+ // event at all — neither should count as a manual edit (Change 3).
112
+ const isUserKeystroke = !!e && e.isTrusted === true;
113
+ if (isUserKeystroke && analysisSuppressed) analysisSuppressed = false;
114
+
115
  const text = getFieldText(activeField);
116
 
117
+ // Ghost-text autocomplete (textarea/input only) runs independently of
118
+ // analysis. Kept active even when suppressed (separate, opt-in feature).
119
  scheduleGhost();
120
 
121
+ if (analysisSuppressed) {
122
+ // Model output (summarize/dialect/quran) was just written — skip the
123
+ // correction pipeline and show the clean badge, not the spinner.
124
+ clearHighlights();
125
+ updateBadge(0);
126
+ return;
127
+ }
128
+
129
  if (!BayanController.hasArabic(text)) {
130
  clearHighlights();
131
  updateBadge(0);
 
563
  }
564
  if (suggestions.length > 0) {
565
  try {
566
+ // Tag the source field so write-back can re-find it after focus
567
+ // moves to the side panel (Change 1).
568
+ if (lastInteractedField) lastInteractedField.dataset.bayanSource = '1';
569
  chrome.runtime.sendMessage({ type: 'OPEN_SIDEPANEL', text: lastAnalyzedText });
570
  } catch {}
571
  }
 
619
  detachField();
620
 
621
  activeField = field;
622
+ lastInteractedField = field; // persists for write-back even after the panel takes focus
623
  suggestions = [];
624
  if (paused) paused = false;
625
 
 
650
  if (floatingBtn) floatingBtn.classList.remove('bayan-il-fab--visible');
651
  }
652
 
653
+ // ══════════════════════════════════════════════════════════
654
+ // Write-back from the side panel (Change 1)
655
+ //
656
+ // The side panel cannot touch page DOM, so it relays text through
657
+ // background.js → here. We write into the source field and dispatch
658
+ // a synthetic `input` event so the host page registers the change.
659
+ // For non-correction sources (summarize/dialect/quran) we set the
660
+ // suppression flag first (Change 3) so the corrected/model output is
661
+ // NOT immediately re-analyzed.
662
+ // ══════════════════════════════════════════════════════════
663
+
664
+ const NON_CORRECTION_SOURCES = ['summarize', 'dialect', 'quran'];
665
+
666
+ function writeTextToField(field, text, mode, source) {
667
+ if (!field || typeof text !== 'string') return;
668
+
669
+ const tag = field.tagName.toLowerCase();
670
+ const suppress = NON_CORRECTION_SOURCES.includes(source);
671
+
672
+ if (tag === 'textarea' || tag === 'input') {
673
+ if (mode === 'replaceSelection'
674
+ && typeof field.selectionStart === 'number'
675
+ && field.selectionStart !== field.selectionEnd) {
676
+ const start = field.selectionStart;
677
+ const end = field.selectionEnd;
678
+ field.value = field.value.slice(0, start) + text + field.value.slice(end);
679
+ const caret = start + text.length;
680
+ try { field.setSelectionRange(caret, caret); } catch {}
681
+ } else {
682
+ field.value = text;
683
+ const caret = field.value.length;
684
+ try { field.setSelectionRange(caret, caret); } catch {}
685
+ }
686
+ // Set suppression BEFORE dispatching so onFieldInput sees it.
687
+ if (suppress) analysisSuppressed = true;
688
+ field.dispatchEvent(new Event('input', { bubbles: true }));
689
+ } else {
690
+ // contenteditable — mirror the overlay-safe approach used by applyFix.
691
+ field.focus();
692
+ const sel = window.getSelection();
693
+ const hasLiveSelection = sel && sel.rangeCount > 0 && field.contains(sel.anchorNode);
694
+ // NOTE: execCommand('insertText') fires a TRUSTED input event, which
695
+ // onFieldInput would treat as a user keystroke and clear suppression.
696
+ // So only take that path when NOT suppressing; suppressed writes use the
697
+ // textContent path, whose dispatched event is untrusted (Change 3-safe).
698
+ if (mode === 'replaceSelection' && hasLiveSelection && !sel.isCollapsed && !suppress) {
699
+ document.execCommand('insertText', false, text);
700
+ } else {
701
+ field.textContent = text;
702
+ if (suppress) analysisSuppressed = true;
703
+ field.dispatchEvent(new Event('input', { bubbles: true }));
704
+ }
705
+ }
706
+
707
+ // Clear the source tag now that the write succeeded.
708
+ try { delete field.dataset.bayanSource; } catch {}
709
+ }
710
+
711
+ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
712
+ if (msg && msg.type === 'BAYAN_WRITE_BACK') {
713
+ const field = lastInteractedField
714
+ || document.querySelector('[data-bayan-source="1"]')
715
+ || (isEditableField(document.activeElement) ? document.activeElement : null);
716
+ // With all_frames:true this listener runs in every frame. A frame that
717
+ // doesn't own the source field stays SILENT (return false, no response)
718
+ // so it can't win the response race against the frame that does.
719
+ if (!field) return false;
720
+ try {
721
+ writeTextToField(field, msg.text, msg.mode, msg.source);
722
+ sendResponse({ ok: true });
723
+ } catch (err) {
724
+ console.warn('[Bayan] Write-back error:', err.message);
725
+ sendResponse({ ok: false, reason: 'write_error' });
726
+ }
727
+ return true;
728
+ }
729
+ return false;
730
+ });
731
+
732
  // ══════════════════════════════════════════════════════════
733
  // Global listeners
734
  // ══════════════════════════════════════════════════════════
extension/sidepanel/sidepanel.js CHANGED
@@ -18,7 +18,7 @@
18
 
19
  document.addEventListener('DOMContentLoaded', () => {
20
  // ── Tab constants ──
21
- const TAB = { CORRECT: 'correct', SUMMARIZE: 'summarize' };
22
 
23
  // ── Element references ──
24
  const inputText = document.getElementById('input-text');
@@ -149,6 +149,26 @@ document.addEventListener('DOMContentLoaded', () => {
149
  toast._timer = setTimeout(() => toast.classList.remove('is-visible'), duration);
150
  }
151
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
152
  // ══════════════════════════════════════════════════════════
153
  // Score ring
154
  // ══════════════════════════════════════════════════════════
@@ -214,6 +234,7 @@ document.addEventListener('DOMContentLoaded', () => {
214
  renderSuggestions(currentSuggestions);
215
  resultText.innerHTML = renderHighlightedText(analyzedText, currentSuggestions);
216
  saveState();
 
217
  showToast('✓ تم التصحيح');
218
  });
219
  });
@@ -358,6 +379,7 @@ document.addEventListener('DOMContentLoaded', () => {
358
  updateScore(0, 0, 0);
359
  renderSuggestions([]);
360
  saveState();
 
361
  showToast('✓ تم تطبيق جميع التصحيحات');
362
  });
363
 
@@ -581,6 +603,34 @@ document.addEventListener('DOMContentLoaded', () => {
581
  addDownloadButton(btnCopyResult, () => resultText.textContent, 'bayan-corrected.txt');
582
  addDownloadButton(btnCopySummary, () => summaryText.textContent, 'bayan-summary.txt');
583
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
584
  // ══════════════════════════════════════════════════════════
585
  // Status check
586
  // ══════════════════════════════════════════════════════════
@@ -605,6 +655,34 @@ document.addEventListener('DOMContentLoaded', () => {
605
  // If the panel is ALREADY open, the storage.onChanged listener
606
  // below catches new actions in real-time.
607
  // ══════════════════════════════════════════════════════════
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
608
  async function tryPickupContext(retryCount = 0) {
609
  if (typeof chrome === 'undefined' || !chrome.storage) return;
610
  if (contextConsumed) return;
@@ -638,25 +716,7 @@ document.addEventListener('DOMContentLoaded', () => {
638
 
639
  console.log(`[Bayan SP] Context action: ${data.contextAction}, text: ${data.contextText.length} chars`);
640
 
641
- if (data.contextAction === TAB.CORRECT) {
642
- inputText.value = data.contextText;
643
- updateCounts(inputText, charCount, wordCount);
644
-
645
- const correctTab = document.querySelector(`[data-tab="${TAB.CORRECT}"]`);
646
- if (correctTab) correctTab.click();
647
-
648
- // Auto-analyze immediately
649
- setTimeout(() => runAnalysis(data.contextText), 100);
650
-
651
- } else if (data.contextAction === TAB.SUMMARIZE) {
652
- summaryInputText.value = data.contextText;
653
- updateCounts(summaryInputText, summaryCharCount, null);
654
-
655
- const summarizeTab = document.querySelector(`[data-tab="${TAB.SUMMARIZE}"]`);
656
- if (summarizeTab) summarizeTab.click();
657
-
658
- setTimeout(() => btnSummarize.click(), 100);
659
- }
660
 
661
  chrome.runtime.sendMessage({ type: 'CLEAR_CONTEXT' });
662
 
@@ -684,24 +744,7 @@ document.addEventListener('DOMContentLoaded', () => {
684
 
685
  console.log(`[Bayan SP] Storage changed — new context: ${action}, ${text.length} chars`);
686
 
687
- if (action === TAB.CORRECT) {
688
- inputText.value = text;
689
- updateCounts(inputText, charCount, wordCount);
690
-
691
- const correctTab = document.querySelector(`[data-tab="${TAB.CORRECT}"]`);
692
- if (correctTab) correctTab.click();
693
-
694
- runAnalysis(text);
695
-
696
- } else if (action === TAB.SUMMARIZE) {
697
- summaryInputText.value = text;
698
- updateCounts(summaryInputText, summaryCharCount, null);
699
-
700
- const summarizeTab = document.querySelector(`[data-tab="${TAB.SUMMARIZE}"]`);
701
- if (summarizeTab) summarizeTab.click();
702
-
703
- setTimeout(() => btnSummarize.click(), 100);
704
- }
705
 
706
  chrome.runtime.sendMessage({ type: 'CLEAR_CONTEXT' });
707
  });
 
18
 
19
  document.addEventListener('DOMContentLoaded', () => {
20
  // ── Tab constants ──
21
+ const TAB = { CORRECT: 'correct', SUMMARIZE: 'summarize', DIALECT: 'dialect', QURAN: 'quran' };
22
 
23
  // ── Element references ──
24
  const inputText = document.getElementById('input-text');
 
149
  toast._timer = setTimeout(() => toast.classList.remove('is-visible'), duration);
150
  }
151
 
152
+ // ══════════════════════════════════════════════════════════
153
+ // Write-back to the page field (panel → background → content script)
154
+ // The side panel is a separate document and cannot touch page DOM
155
+ // directly; it relays through background.js. `source` lets the content
156
+ // script decide whether to re-analyze (correct) or suppress (Change 3).
157
+ // ══════════════════════════════════════════════════════════
158
+ function writeBackToPage(text, mode = 'replaceAll', source = 'correct') {
159
+ try {
160
+ chrome.runtime.sendMessage(
161
+ { type: 'WRITE_BACK_TO_PAGE', text, mode, source },
162
+ (resp) => {
163
+ if (resp && resp.ok) showToast('✓ تم تطبيق التغييرات في الصفحة');
164
+ else showToast('تعذّر الكتابة في الصفحة — انسخ النص يدوياً');
165
+ }
166
+ );
167
+ } catch {
168
+ showToast('تعذّر الكتابة في الصفحة — انسخ النص يدوياً');
169
+ }
170
+ }
171
+
172
  // ══════════════════════════════════════════════════════════
173
  // Score ring
174
  // ══════════════════════════════════════════════════════════
 
234
  renderSuggestions(currentSuggestions);
235
  resultText.innerHTML = renderHighlightedText(analyzedText, currentSuggestions);
236
  saveState();
237
+ writeBackToPage(analyzedText, 'replaceAll', 'correct');
238
  showToast('✓ تم التصحيح');
239
  });
240
  });
 
379
  updateScore(0, 0, 0);
380
  renderSuggestions([]);
381
  saveState();
382
+ writeBackToPage(analyzedText, 'replaceAll', 'correct');
383
  showToast('✓ تم تطبيق جميع التصحيحات');
384
  });
385
 
 
603
  addDownloadButton(btnCopyResult, () => resultText.textContent, 'bayan-corrected.txt');
604
  addDownloadButton(btnCopySummary, () => summaryText.textContent, 'bayan-summary.txt');
605
 
606
+ // ══════════════════════════════════════════════════════════
607
+ // "Apply to page" buttons for summarize / dialect / quran results.
608
+ // These write the model output back into the source page field via
609
+ // Change 1's relay, tagging the write with its `source` so the content
610
+ // script suppresses correction re-analysis on it (Change 3).
611
+ // Injected programmatically to avoid touching sidepanel.html.
612
+ // ══════════════════════════════════════════════════════════
613
+ const SP_APPLY_PAGE_ICON = '<svg width="14" height="14" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>';
614
+
615
+ function addApplyToPageButton(anchorBtn, getText, source) {
616
+ if (!anchorBtn || !anchorBtn.parentElement) return;
617
+ const btn = document.createElement('button');
618
+ btn.className = 'sp-btn-icon';
619
+ btn.type = 'button';
620
+ btn.title = 'تطبيق في الصفحة';
621
+ btn.innerHTML = SP_APPLY_PAGE_ICON;
622
+ btn.addEventListener('click', () => {
623
+ const text = (getText() || '').trim();
624
+ if (!text) { showToast('لا يوجد نص للتطبيق'); return; }
625
+ writeBackToPage(text, 'replaceAll', source);
626
+ });
627
+ anchorBtn.parentElement.appendChild(btn);
628
+ }
629
+
630
+ addApplyToPageButton(btnCopySummary, () => summaryText.textContent, 'summarize');
631
+ if (btnCopyDialect) addApplyToPageButton(btnCopyDialect, () => dialectText.textContent, 'dialect');
632
+ if (btnCopyQuran) addApplyToPageButton(btnCopyQuran, () => quranText.textContent, 'quran');
633
+
634
  // ══════════════════════════════════════════════════════════
635
  // Status check
636
  // ══════════════════════════════════════════════════════════
 
655
  // If the panel is ALREADY open, the storage.onChanged listener
656
  // below catches new actions in real-time.
657
  // ══════════════════════════════════════════════════════════
658
+
659
+ // Dispatch a context action (correct/summarize/dialect/quran) by filling
660
+ // the matching tab's input, switching to it, and auto-running its model.
661
+ // Declared after all element refs so dialect/quran handles are in scope.
662
+ function runContextAction(action, text) {
663
+ if (action === TAB.CORRECT) {
664
+ inputText.value = text;
665
+ updateCounts(inputText, charCount, wordCount);
666
+ document.querySelector(`[data-tab="${TAB.CORRECT}"]`)?.click();
667
+ setTimeout(() => runAnalysis(text), 100);
668
+ } else if (action === TAB.SUMMARIZE) {
669
+ summaryInputText.value = text;
670
+ updateCounts(summaryInputText, summaryCharCount, null);
671
+ document.querySelector(`[data-tab="${TAB.SUMMARIZE}"]`)?.click();
672
+ setTimeout(() => btnSummarize.click(), 100);
673
+ } else if (action === TAB.DIALECT && dialectInput && btnDialect) {
674
+ dialectInput.value = text;
675
+ updateCounts(dialectInput, dialectCharCount, null);
676
+ document.querySelector(`[data-tab="${TAB.DIALECT}"]`)?.click();
677
+ setTimeout(() => btnDialect.click(), 120);
678
+ } else if (action === TAB.QURAN && quranInput && btnQuran) {
679
+ quranInput.value = text;
680
+ updateCounts(quranInput, quranCharCount, null);
681
+ document.querySelector(`[data-tab="${TAB.QURAN}"]`)?.click();
682
+ setTimeout(() => btnQuran.click(), 120);
683
+ }
684
+ }
685
+
686
  async function tryPickupContext(retryCount = 0) {
687
  if (typeof chrome === 'undefined' || !chrome.storage) return;
688
  if (contextConsumed) return;
 
716
 
717
  console.log(`[Bayan SP] Context action: ${data.contextAction}, text: ${data.contextText.length} chars`);
718
 
719
+ runContextAction(data.contextAction, data.contextText);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
720
 
721
  chrome.runtime.sendMessage({ type: 'CLEAR_CONTEXT' });
722
 
 
744
 
745
  console.log(`[Bayan SP] Storage changed — new context: ${action}, ${text.length} chars`);
746
 
747
+ runContextAction(action, text);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
748
 
749
  chrome.runtime.sendMessage({ type: 'CLEAR_CONTEXT' });
750
  });