fix(magazine): correct flagged words in place; whole-text editor keeps the paragraph's look and underlines

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
mARTin-B78 2026-09-30 11:54:50 +02:00
parent dd5ddc5842
commit 119d3ba3ae
5 changed files with 248 additions and 5 deletions

View File

@ -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).
---

View File

@ -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.

View File

@ -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 = '<input type="text" class="mag-word-input" maxlength="120" autocomplete="off" spellcheck="false" ' +
'aria-label="Correct the word ' + esc(issue.token) + '" value="' + esc(original) + '" style="width:' + Math.max(3, original.length + 1) + 'ch">' +
'<span class="mag-word-pop" role="group" aria-label="Corrections for ' + esc(issue.token) + '">' +
(issue.suggestions || []).slice(0, 5).map((sug, k) => '<button type="button" class="btn-sm" data-sug="' + k + '">' + esc(sug) + '</button>').join('') +
'<button type="button" class="btn-sm" data-keep title="This word is fine (a name or technical term): stop flagging it in this issue">Keep</button>' +
'<span class="mag-word-hint muted">Enter applies · Esc cancels</span></span>';
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('(?<![\\p{L}\\p{N}])(?:' + words.map((w) => 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)) + '<mark class="mag-spell">' + esc(m[0]) + '</mark>';
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 = '<textarea aria-label="Text of this region" rows="5"></textarea>' +
editor.innerHTML = '<span class="mag-hl-wrap"><span class="mag-hl-back" aria-hidden="true"></span>' +
'<textarea class="mag-hl-area" aria-label="Text of this region" rows="1" spellcheck="false"></textarea></span>' +
'<span class="mag-row-editor-actions">' +
'<button type="button" class="btn-sm btn-primary" data-act="save">Save text</button>' +
'<button type="button" class="btn-sm" data-act="split" title="Split this region into two where the cursor is">Split here</button>' +
@ -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)) + '<mark class="mag-spell" title="' + esc(hint) + '">' +
// 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)) + '<mark class="mag-spell' + (live ? ' mag-spell-edit' : '') + '" title="' + esc(hint + (live ? ' — click to correct' : '')) + '"' +
(live ? ' role="button" tabindex="0" data-spell-start="' + Number(i.start) + '" data-spell-end="' + Number(i.end) + '"' +
' onclick="magWordEdit(\'' + esc(blockId) + '\', ' + Number(i.start) + ', ' + Number(i.end) + ', event)"' +
' onkeydown="if(event.key===\'Enter\'||event.key===\' \'){magWordEdit(\'' + esc(blockId) + '\', ' + Number(i.start) + ', ' + Number(i.end) + ', event);}"' : '') + '>' +
esc(text.slice(i.start, i.end)) + '</mark>';
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;

View File

@ -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 */

View File

@ -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.' });
});
});