diff --git a/CHANGELOG.md b/CHANGELOG.md
index bf8474e..dbd8831 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,9 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
---
## [Unreleased]
+### Changed
+- **Fixing flagged words in the review list no longer restyles the paragraph or hides the underlines.** Clicking an underlined word now edits just that word in place (a small field in the paragraph's own type, suggestion chips, Enter applies, Esc cancels, *Keep* for names), through the same checked one-word fix as the spelling list. Clicking elsewhere in the text opens the whole-text editor, which now keeps the paragraph's font size, line height, width and wrapping (it was 13 px in a full-row box against 14 px text in a narrower column), grows with its text instead of scrolling in a small box, and keeps the doubtful words underlined while you type. On a phone the row text and editors are both 16 px (what iOS needs to avoid zooming in).
+
---
diff --git a/Manual.md b/Manual.md
index 3cb7f04..e6512df 100644
--- a/Manual.md
+++ b/Manual.md
@@ -397,7 +397,12 @@ where the magazine's licence allows.
**Purpose.** Fix what OCR got wrong and always be able to see, and undo, what you changed.
-**Steps.** In Review, click a region's text (or its pencil) and press *Save text*. A region you edited shows an
+**Fix a flagged word where it stands.** Words the spelling check doubts are underlined in the region's text. Click one (or Tab to it
+and press Enter): only that word turns into a small field, in the same type as the paragraph, so nothing around it moves or
+changes. Type the correction and press **Enter**, or click one of the suggested words; **Esc** puts the word back; **Keep**
+stops flagging a name or technical term in this issue. Each fix can be undone with Undo, like every other correction.
+
+**Steps.** In Review, click a region's text away from an underlined word (or its pencil) to edit the whole text in place: it keeps the paragraph's type, width and line breaks, grows with what you type, and the doubtful words stay underlined (an underline goes as soon as the word is changed). Press *Save text*. A region you edited shows an
**Edited** mark. *Show changes* opens a word-by-word comparison with the text as first recognised: words removed are
struck through and marked with a minus, words added are underlined and marked with a plus (never colour alone).
*Restore recognised text* puts the original back. Typing the original back yourself also clears the mark.
diff --git a/static/js/magazine.js b/static/js/magazine.js
index 28edfdf..d59371b 100644
--- a/static/js/magazine.js
+++ b/static/js/magazine.js
@@ -3020,6 +3020,71 @@ async function magRowRestore(id) {
magReloadReview('Recognised text restored');
}
+// ── Correct a flagged word where it stands ───────────────────────────────────
+// The word becomes a small input set in the paragraph's own type (nothing around it reflows or restyles), with
+// the dictionary's suggestions as one-click chips. Enter applies, Esc puts the word back. The fix goes through
+// the same checked route as the spelling list (offsets re-verified server-side, one Undo).
+function magWordEdit(blockId, start, end, ev) {
+ if (ev) { ev.preventDefault(); ev.stopPropagation(); }
+ const issues = state.spell?.issues || [];
+ const n = issues.findIndex((i) => i.block_id === blockId && i.start === start && i.end === end);
+ const row = document.querySelector('#mag-review [data-block-row="' + CSS.escape(blockId) + '"]');
+ const mark = row?.querySelector('mark[data-spell-start="' + Number(start) + '"]');
+ if (n < 0 || !mark || mark.querySelector('input')) return;
+ const issue = issues[n];
+ const original = issue.raw || issue.token;
+ const shown = mark.textContent;
+ mark.classList.add('is-editing');
+ mark.removeAttribute('role');
+ mark.innerHTML = '' +
+ '' +
+ (issue.suggestions || []).slice(0, 5).map((sug, k) => '').join('') +
+ '' +
+ 'Enter applies · Esc cancels';
+ const input = mark.querySelector('input');
+ const restore = () => { mark.classList.remove('is-editing'); mark.setAttribute('role', 'button'); mark.textContent = shown; };
+ const apply = (value) => {
+ const v = String(value).replace(/\s+/g, ' ').trim();
+ if (!v) return say('Type the corrected word first', 'err');
+ if (v === original) return restore();
+ magSpellApply(n, v);
+ };
+ ['click', 'keyup'].forEach((t) => mark.addEventListener(t, (e) => { if (mark.classList.contains('is-editing')) e.stopPropagation(); }));
+ input.addEventListener('keydown', (e) => {
+ e.stopPropagation();
+ if (e.key === 'Enter') { e.preventDefault(); apply(input.value); }
+ else if (e.key === 'Escape') { e.preventDefault(); restore(); }
+ });
+ input.addEventListener('input', () => { input.style.width = Math.max(3, input.value.length + 1) + 'ch'; });
+ mark.querySelector('.mag-word-pop').addEventListener('click', (e) => {
+ const btn = e.target.closest('button');
+ if (!btn) return;
+ e.preventDefault(); e.stopPropagation();
+ if (btn.dataset.keep !== undefined) return magSpellKeep(n);
+ apply(issue.suggestions[Number(btn.dataset.sug)]);
+ });
+ input.addEventListener('blur', () => setTimeout(() => {
+ if (mark.isConnected && mark.classList.contains('is-editing') && !mark.contains(document.activeElement)) restore();
+ }, 150));
+ input.focus();
+ input.select();
+}
+
+// The flagged words of a block as a regex over its *current* text, so underlines survive typing: a word stops
+// being marked the moment it no longer matches, with no stale offsets.
+function magMarkTokens(text, tokens) {
+ const words = [...new Set((tokens || []).filter(Boolean))].sort((a, b) => b.length - a.length);
+ if (!words.length) return esc(text);
+ const re = new RegExp('(? w.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|') + ')(?![\\p{L}\\p{N}])', 'gu');
+ let out = '', pos = 0, m;
+ while ((m = re.exec(text))) {
+ out += esc(text.slice(pos, m.index)) + '' + esc(m[0]) + '';
+ pos = m.index + m[0].length;
+ }
+ return out + esc(text.slice(pos));
+}
+
async function magRowEdit(id) {
if (state.reloadPending) await state.reloadPending;
const block = (state.layout?.blocks || []).find((b) => b.id === id);
@@ -3029,7 +3094,8 @@ async function magRowEdit(id) {
span.hidden = true;
const editor = document.createElement('span');
editor.className = 'mag-row-editor';
- editor.innerHTML = '' +
+ editor.innerHTML = '' +
+ '' +
'' +
'' +
'' +
@@ -3037,6 +3103,16 @@ async function magRowEdit(id) {
span.after(editor);
const area = editor.querySelector('textarea');
area.value = block.text;
+ // Set in the paragraph's own type, growing with its text, with the flagged words still underlined while you type.
+ const back = editor.querySelector('.mag-hl-back');
+ const tokens = ((state.spellByBlock && state.spellByBlock.get(id)) || []).map((i) => i.raw || i.token);
+ const sync = () => {
+ back.innerHTML = magMarkTokens(area.value, tokens) + (area.value.endsWith('\n') ? '\n ' : '');
+ area.style.height = 'auto';
+ area.style.height = area.scrollHeight + 'px';
+ };
+ area.addEventListener('input', sync);
+ sync();
['click', 'keydown', 'keyup'].forEach((ev) => editor.addEventListener(ev, (e) => e.stopPropagation()));
area.addEventListener('keydown', (e) => { if (e.key === 'Escape') { e.preventDefault(); magReloadReview(); } });
editor.addEventListener('click', async (e) => {
@@ -4044,7 +4120,12 @@ function magSpellMarked(blockId, text) {
issues.filter((i) => i.end <= text.length).sort((x, y) => x.start - y.start).forEach((i) => {
if (i.start < pos) return;
const hint = 'Possible misspelling' + (i.suggestions.length ? ' — try: ' + i.suggestions.join(', ') : '');
- out += esc(text.slice(pos, i.start)) + '' +
+ // In a review row a flagged word is a control: click it to correct just that word, in place.
+ const live = state.view === 'review' && i.block_id === blockId;
+ out += esc(text.slice(pos, i.start)) + '' +
esc(text.slice(i.start, i.end)) + '';
pos = i.end;
});
@@ -5653,6 +5734,8 @@ root.magRunPageQuality = magRunPageQuality;
root.magFixReadingOrder = magFixReadingOrder;
root.magCleanUpBoxes = magCleanUpBoxes;
root.magRowEdit = magRowEdit;
+root.magWordEdit = magWordEdit;
+root.magMarkTokens = magMarkTokens;
root.magRowDiff = magRowDiff;
root.magRowRestore = magRowRestore;
root.magWordDiff = magWordDiff;
diff --git a/static/style.css b/static/style.css
index b20240a..561229c 100644
--- a/static/style.css
+++ b/static/style.css
@@ -5328,7 +5328,9 @@ input[list="lang-codes"] {
/* The type picker and delete button stay on small screens; the text drops to its own line. */
.mag-block-type { grid-column: 3; }
.mag-block-type > .mdi { display: none; }
- .mag-block-text, .mag-block-thumb-wrap { grid-column: 1 / -1; }
+ .mag-block-text, .mag-block-thumb-wrap, .mag-row-editor { grid-column: 1 / -1; }
+ /* iOS zooms into any field under 16px, so on a phone the paragraph is 16px too: the editors then match it exactly. */
+ .mag-block-text { font-size: 16px; }
.mag-article { font-size: 18px; }
.mag-driving-controls { gap: 20px; }
.mag-driving-btn { width: 76px; height: 76px; font-size: 28px; }
@@ -5366,7 +5368,7 @@ input[list="lang-codes"] {
.mag-row-actions { display: inline-flex; gap: 2px; grid-column: -2 / -1; justify-self: end; }
.mag-row-action { padding: 2px 6px; min-width: 28px; min-height: 28px; color: var(--subtext); }
.mag-row-action:hover, .mag-row-action:focus-visible { color: var(--text); }
-.mag-row-editor { display: flex; flex-direction: column; gap: 6px; grid-column: 1 / -1; }
+.mag-row-editor { display: flex; flex-direction: column; gap: 6px; min-width: 0; } /* the text's own column, so the wrapping does not change */
.mag-row-editor textarea { width: 100%; font: inherit; font-size: 13px; padding: 6px; border: 1px solid var(--border);
border-radius: var(--radius); background: var(--surface); color: var(--text); resize: vertical; }
.mag-row-editor-actions { display: flex; flex-wrap: wrap; gap: 6px; }
@@ -5625,3 +5627,21 @@ details.mag-report-group summary { cursor: pointer; margin-bottom: 6px; }
border: 1px solid var(--border); border-radius: var(--radius); overflow-wrap: anywhere; }
.mag-cat-form { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 10px; align-items: end; }
.mag-cat-form label { display: flex; flex-direction: column; gap: 4px; font-size: 13px; }
+
+/* Editing a region's text in place: same type and wrapping as the displayed paragraph, flagged words still underlined */
+.mag-hl-wrap { position: relative; display: block; font-size: 14px; line-height: 1.45; }
+.mag-hl-back, .mag-hl-area { font: inherit; line-height: inherit; letter-spacing: inherit; white-space: pre-wrap; overflow-wrap: break-word;
+ word-break: normal; padding: 0; margin: 0; border: 0; width: 100%; box-sizing: border-box; }
+.mag-hl-back { position: absolute; inset: 0; color: transparent; pointer-events: none; overflow: hidden; }
+.mag-hl-back .mag-spell { color: transparent; }
+.mag-row-editor textarea.mag-hl-area { position: relative; display: block; font: inherit; font-size: inherit; line-height: inherit; padding: 0; border: 0; width: 100%; background: transparent; color: var(--text); resize: none; overflow: hidden;
+ min-height: 1.45em; border-radius: 4px; outline: none; box-shadow: 0 0 0 4px color-mix(in srgb, var(--accent) 18%, transparent), 0 0 0 5px var(--accent); }
+/* One flagged word being corrected where it stands */
+.mag-spell.is-editing { text-decoration: none; background: none; outline: none; position: relative; cursor: text; }
+.mag-word-input { font: inherit; line-height: inherit; color: var(--text); background: var(--surface); padding: 0 2px; margin: 0; min-width: 3ch; max-width: 100%;
+ border: 0; border-radius: 3px; box-shadow: 0 0 0 2px var(--accent); }
+.mag-word-pop { position: absolute; left: 0; top: 100%; z-index: 40; display: flex; flex-wrap: wrap; gap: 4px; align-items: center; margin-top: 4px; padding: 6px;
+ min-width: 220px; max-width: min(420px, 80vw); border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface);
+ box-shadow: 0 8px 24px rgba(0, 0, 0, .25); font-size: 13px; font-style: normal; line-height: 1.3; }
+.mag-word-hint { flex: 1 1 100%; font-size: 12px; }
+@media (max-width: 860px) { .mag-hl-wrap { font-size: 16px; } } /* after the desktop rule above, so it wins */
diff --git a/tests/magazine.spec.js b/tests/magazine.spec.js
index d40c458..b08fc87 100644
--- a/tests/magazine.spec.js
+++ b/tests/magazine.spec.js
@@ -1857,3 +1857,135 @@ test('a refused catalogue shows the server reason and keeps the form', async ({
await expect(page.locator('#mag-cat-status')).toContainText('Put the login in the username field');
await expect(page.locator('#mag-cat-url')).toHaveValue('http://me:pw@nas.local/opds');
});
+
+test.describe('Correcting flagged words in the review list', () => {
+ const ISSUES = [
+ { token: 'technology', raw: 'technology', block_id: 'b3', page: 3, start: 8, end: 18, language: 'en', suggestions: ['technologie', 'technologies'], context: 'Battery technology is entering' },
+ { token: 'mature', raw: 'mature', block_id: 'b3', page: 3, start: 67, end: 73, language: 'en', suggestions: [], context: 'years to mature.' },
+ ];
+
+ async function open(page) {
+ await mockBackend(page);
+ const posts = [];
+ await page.route(`**/api/magazine/${DOC_ID}/spelling/dictionary`, route => { posts.push({ keep: route.request().postDataJSON() }); return route.fulfill({ json: { ok: true } }); });
+ await page.route(`**/api/magazine/${DOC_ID}/spelling/fix`, route => { posts.push({ fix: route.request().postDataJSON() }); return route.fulfill({ json: { ok: true, undo: 'tok', text: 'x' } }); });
+ await page.route(`**/api/magazine/${DOC_ID}/spelling?**`, route => route.fulfill({ json: { issues: posts.some(p => p.fix || p.keep) ? [] : ISSUES, dictionary: {} } }));
+ await page.goto('/', { waitUntil: 'domcontentloaded' });
+ await page.waitForSelector('#app-skeleton', { state: 'detached', timeout: 15_000 });
+ await navTo(page, 's-magazine');
+ await page.locator('#mag-library-list .mag-card').first().click();
+ await page.locator('#mag-contents-view-toggle').click();
+ await page.locator('.mag-card-wrap').filter({ hasText: 'Page 3' }).getByRole('button', { name: /^Review/ }).click();
+ await page.waitForSelector('#mag-undo-btn');
+ await expect(page.locator('[data-block-row="b3"] mark.mag-spell')).toHaveCount(2);
+ return posts;
+ }
+ const fontOf = (page, sel) => page.locator(sel).first().evaluate(el => { const c = getComputedStyle(el); return `${c.fontSize}/${c.lineHeight}/${c.fontFamily}`; });
+
+ test('clicking a flagged word edits only that word, in the paragraph\'s own type, and leaves the other marks', async ({ page }) => {
+ await open(page);
+ const before = await fontOf(page, '[data-block-row="b3"] .mag-block-text');
+ const box = await page.locator('[data-block-row="b3"] .mag-block-text').boundingBox();
+ await page.locator('[data-block-row="b3"] mark.mag-spell').first().click();
+ const input = page.locator('[data-block-row="b3"] .mag-word-input');
+ await expect(input).toBeFocused();
+ await expect(input).toHaveValue('technology');
+ await expect(page.locator('[data-block-row="b3"] .mag-row-editor')).toHaveCount(0); // not the whole-text editor
+ await expect(page.locator('[data-block-row="b3"] mark.mag-spell:not(.is-editing)')).toHaveCount(1); // the other word stays underlined
+ expect(await fontOf(page, '[data-block-row="b3"] .mag-block-text')).toBe(before); // nothing restyled
+ expect(await fontOf(page, '[data-block-row="b3"] .mag-word-input')).toBe(before);
+ const after = await page.locator('[data-block-row="b3"] .mag-block-text').boundingBox();
+ expect(Math.abs(after.width - box.width)).toBeLessThan(2); // the column did not change width
+ await expect(page.locator('[data-block-row="b3"] input[type="checkbox"]')).toBeChecked(); // the click did not untick the region
+ });
+
+ test('a suggestion chip corrects the word with one click', async ({ page }) => {
+ const posts = await open(page);
+ await page.locator('[data-block-row="b3"] mark.mag-spell').first().click();
+ await page.locator('[data-block-row="b3"] .mag-word-pop').getByRole('button', { name: 'technologie', exact: true }).click();
+ await expect.poll(() => posts.find(p => p.fix)?.fix).toMatchObject({ block_id: 'b3', start: 8, end: 18, token: 'technology', replacement: 'technologie' });
+ });
+
+ test('typing a correction and pressing Enter applies it; Escape puts the word back and sends nothing', async ({ page }) => {
+ const posts = await open(page);
+ const mark = page.locator('[data-block-row="b3"] mark.mag-spell').first();
+ await mark.click();
+ const input = page.locator('[data-block-row="b3"] .mag-word-input');
+ await input.fill('Technologie');
+ await input.press('Escape');
+ await expect(page.locator('[data-block-row="b3"] .mag-word-input')).toHaveCount(0);
+ await expect(page.locator('[data-block-row="b3"] mark.mag-spell').first()).toHaveText('technology');
+ expect(posts).toEqual([]);
+ await page.locator('[data-block-row="b3"] mark.mag-spell').first().click();
+ await page.locator('[data-block-row="b3"] .mag-word-input').fill('Technologie');
+ await page.locator('[data-block-row="b3"] .mag-word-input').press('Enter');
+ await expect.poll(() => posts.find(p => p.fix)?.fix).toMatchObject({ token: 'technology', replacement: 'Technologie', start: 8, end: 18 });
+ });
+
+ test('an empty or unchanged word is not sent', async ({ page }) => {
+ const posts = await open(page);
+ await page.locator('[data-block-row="b3"] mark.mag-spell').first().click();
+ const input = page.locator('[data-block-row="b3"] .mag-word-input');
+ await input.fill(' ');
+ await input.press('Enter');
+ await expect(input).toBeVisible(); // still editing: nothing to apply
+ await input.fill('technology');
+ await input.press('Enter');
+ await expect(page.locator('[data-block-row="b3"] .mag-word-input')).toHaveCount(0);
+ expect(posts).toEqual([]);
+ });
+
+ test('Keep adds the word to the issue dictionary', async ({ page }) => {
+ const posts = await open(page);
+ await page.locator('[data-block-row="b3"] mark.mag-spell').nth(1).click();
+ await page.locator('[data-block-row="b3"] .mag-word-pop').getByRole('button', { name: 'Keep' }).click();
+ await expect.poll(() => posts.find(p => p.keep)?.keep).toEqual({ word: 'mature' });
+ });
+
+ test('a flagged word can be reached and opened with the keyboard', async ({ page }) => {
+ await open(page);
+ const mark = page.locator('[data-block-row="b3"] mark.mag-spell').first();
+ await mark.focus();
+ await page.keyboard.press('Enter');
+ await expect(page.locator('[data-block-row="b3"] .mag-word-input')).toBeFocused();
+ });
+
+ test('the whole-text editor keeps the paragraph\'s type, width and the flagged words underlined', async ({ page }) => {
+ await open(page);
+ const textSel = '[data-block-row="b3"] .mag-block-text';
+ const before = await fontOf(page, textSel);
+ const box = await page.locator(textSel).boundingBox();
+ // Click plain text, away from any flagged word.
+ await page.locator(textSel).click({ position: { x: box.width - 4, y: 4 } });
+ const area = page.locator('[data-block-row="b3"] .mag-hl-area');
+ await expect(area).toBeFocused();
+ expect(await fontOf(page, '[data-block-row="b3"] .mag-hl-area')).toBe(before);
+ const editorBox = await area.boundingBox();
+ expect(Math.abs(editorBox.width - box.width)).toBeLessThan(3); // same column, same wrapping
+ await expect(page.locator('[data-block-row="b3"] .mag-hl-back mark.mag-spell')).toHaveCount(2);
+ // Fix one word by typing: its underline goes, the other stays.
+ await area.fill('Battery technologie is entering a new phase. It will take years to mature.');
+ await expect(page.locator('[data-block-row="b3"] .mag-hl-back mark.mag-spell')).toHaveCount(1);
+ await expect(page.locator('[data-block-row="b3"] .mag-hl-back mark.mag-spell')).toHaveText('mature');
+ });
+
+ test('the editor grows with its text instead of scrolling inside a small box', async ({ page }) => {
+ await open(page);
+ await page.locator('[data-block-row="b3"] .mag-block-text').click({ position: { x: 4, y: 4 } });
+ const area = page.locator('[data-block-row="b3"] .mag-hl-area');
+ const h1 = (await area.boundingBox()).height;
+ await area.fill(Array.from({ length: 40 }, (_, i) => 'Zeile ' + i + ' mit etwas mehr Text').join(' '));
+ await expect.poll(async () => (await area.boundingBox()).height).toBeGreaterThan(h1 * 3);
+ expect(await area.evaluate(el => el.scrollHeight <= el.clientHeight + 1)).toBe(true);
+ });
+
+ test('Save text still saves the edited text', async ({ page }) => {
+ await open(page);
+ const patches = [];
+ await page.route(`**/api/magazine/${DOC_ID}/pages/3/regions/b3`, route => { patches.push(route.request().postDataJSON()); route.fulfill({ json: { ok: true } }); });
+ await page.locator('[data-block-row="b3"] .mag-block-text').click({ position: { x: 4, y: 4 } });
+ await page.locator('[data-block-row="b3"] .mag-hl-area').fill('Neuer Text.');
+ await page.locator('[data-block-row="b3"]').getByRole('button', { name: 'Save text' }).click();
+ await expect.poll(() => patches[0]).toEqual({ text: 'Neuer Text.' });
+ });
+});