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