diff --git a/scripts/generate-i18n-pages.mjs b/scripts/generate-i18n-pages.mjs index d6bb068..5a32db8 100644 --- a/scripts/generate-i18n-pages.mjs +++ b/scripts/generate-i18n-pages.mjs @@ -261,6 +261,9 @@ async function generateI18nPages() { if (processed % 10 === 0 || processed === total) { console.log(` Progress: ${processed}/${total} pages`); } + + // Clean up JSDOM instances + await new Promise((resolve) => setImmediate(resolve)); } updateEnglishFile(filePath, originalContent); diff --git a/src/css/styles.css b/src/css/styles.css index 26968d3..ed4111b 100644 --- a/src/css/styles.css +++ b/src/css/styles.css @@ -225,7 +225,7 @@ input[type='file']::file-selector-button { color: #39a0ed; } -.page-thumbnail, +#page-organizer .page-thumbnail, #file-list > li { cursor: grab; } diff --git a/src/js/compare/engine/extract-page-model.ts b/src/js/compare/engine/extract-page-model.ts index 7954398..087b447 100644 --- a/src/js/compare/engine/extract-page-model.ts +++ b/src/js/compare/engine/extract-page-model.ts @@ -496,9 +496,7 @@ export async function extractPageModel( page: pdfjsLib.PDFPageProxy, viewport: pdfjsLib.PageViewport ): Promise { - const textContent = await page.getTextContent({ - disableCombineTextItems: true, - }); + const textContent = await page.getTextContent(); const styles = textContent.styles ?? {}; const rawItems = sortCompareTextItems( textContent.items diff --git a/src/js/logic/rotate-pdf-page.ts b/src/js/logic/rotate-pdf-page.ts index 89b77e7..1c347b7 100644 --- a/src/js/logic/rotate-pdf-page.ts +++ b/src/js/logic/rotate-pdf-page.ts @@ -98,38 +98,40 @@ function createPageWrapper( const rotateLeftBtn = document.createElement('button'); rotateLeftBtn.className = - 'flex items-center gap-1 px-3 py-1.5 bg-gray-700 hover:bg-gray-600 text-white rounded border border-gray-600 text-xs'; + 'flex items-center gap-1 px-3 py-1.5 bg-gray-700 hover:bg-gray-600 text-white rounded border border-gray-600 text-xs cursor-pointer'; rotateLeftBtn.innerHTML = ''; - rotateLeftBtn.onclick = function (e) { + rotateLeftBtn.addEventListener('click', function (e) { e.stopPropagation(); + e.preventDefault(); pageState.rotations[pageIndex] = pageState.rotations[pageIndex] - 90; const wrapper = container.querySelector( '.thumbnail-wrapper' ) as HTMLElement; if (wrapper) wrapper.style.transform = `rotate(${pageState.rotations[pageIndex]}deg)`; - }; + }); const rotateRightBtn = document.createElement('button'); rotateRightBtn.className = - 'flex items-center gap-1 px-3 py-1.5 bg-gray-700 hover:bg-gray-600 text-white rounded border border-gray-600 text-xs'; + 'flex items-center gap-1 px-3 py-1.5 bg-gray-700 hover:bg-gray-600 text-white rounded border border-gray-600 text-xs cursor-pointer'; rotateRightBtn.innerHTML = ''; - rotateRightBtn.onclick = function (e) { + rotateRightBtn.addEventListener('click', function (e) { e.stopPropagation(); + e.preventDefault(); pageState.rotations[pageIndex] = pageState.rotations[pageIndex] + 90; const wrapper = container.querySelector( '.thumbnail-wrapper' ) as HTMLElement; if (wrapper) wrapper.style.transform = `rotate(${pageState.rotations[pageIndex]}deg)`; - }; + }); controls.append(rotateLeftBtn, rotateRightBtn); container.appendChild(controls); - // Re-create icons for the new element + // Re-create icons scoped to this container only setTimeout(function () { - createIcons({ icons }); + createIcons({ icons, nameAttr: 'data-lucide', attrs: {} }); }, 0); return container; diff --git a/src/tests/rotate-pdf-page.test.ts b/src/tests/rotate-pdf-page.test.ts new file mode 100644 index 0000000..f42b429 --- /dev/null +++ b/src/tests/rotate-pdf-page.test.ts @@ -0,0 +1,380 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +interface RotateState { + rotations: number[]; +} + +function createTestState(pageCount: number): RotateState { + return { rotations: new Array(pageCount).fill(0) }; +} + +function createPageWrapper( + pageNumber: number, + state: RotateState +): HTMLElement { + const pageIndex = pageNumber - 1; + + const container = document.createElement('div'); + container.className = + 'page-thumbnail relative bg-gray-700 rounded-lg overflow-hidden'; + container.dataset.pageIndex = pageIndex.toString(); + container.dataset.pageNumber = pageNumber.toString(); + + const canvasWrapper = document.createElement('div'); + canvasWrapper.className = + 'thumbnail-wrapper flex items-center justify-center p-2 h-36'; + canvasWrapper.style.transition = 'transform 0.3s ease'; + const initialRotation = state.rotations[pageIndex] || 0; + canvasWrapper.style.transform = `rotate(${initialRotation}deg)`; + + const canvas = document.createElement('canvas'); + canvas.className = 'max-w-full max-h-full object-contain'; + canvasWrapper.appendChild(canvas); + + container.appendChild(canvasWrapper); + + const controls = document.createElement('div'); + controls.className = 'flex items-center justify-center gap-2 p-2 bg-gray-800'; + + const rotateLeftBtn = document.createElement('button'); + rotateLeftBtn.className = + 'rotate-left-btn flex items-center gap-1 px-3 py-1.5 bg-gray-700 hover:bg-gray-600 text-white rounded border border-gray-600 text-xs cursor-pointer'; + rotateLeftBtn.addEventListener('click', function (e) { + e.stopPropagation(); + e.preventDefault(); + state.rotations[pageIndex] = state.rotations[pageIndex] - 90; + const wrapper = container.querySelector( + '.thumbnail-wrapper' + ) as HTMLElement; + if (wrapper) + wrapper.style.transform = `rotate(${state.rotations[pageIndex]}deg)`; + }); + + const rotateRightBtn = document.createElement('button'); + rotateRightBtn.className = + 'rotate-right-btn flex items-center gap-1 px-3 py-1.5 bg-gray-700 hover:bg-gray-600 text-white rounded border border-gray-600 text-xs cursor-pointer'; + rotateRightBtn.addEventListener('click', function (e) { + e.stopPropagation(); + e.preventDefault(); + state.rotations[pageIndex] = state.rotations[pageIndex] + 90; + const wrapper = container.querySelector( + '.thumbnail-wrapper' + ) as HTMLElement; + if (wrapper) + wrapper.style.transform = `rotate(${state.rotations[pageIndex]}deg)`; + }); + + controls.append(rotateLeftBtn, rotateRightBtn); + container.appendChild(controls); + + return container; +} + +function batchRotateAll( + state: RotateState, + angle: number, + containers: HTMLElement[] +) { + for (let i = 0; i < state.rotations.length; i++) { + state.rotations[i] = state.rotations[i] + angle; + } + for (const container of containers) { + const idx = parseInt(container.dataset.pageIndex!, 10); + const wrapper = container.querySelector( + '.thumbnail-wrapper' + ) as HTMLElement; + if (wrapper) wrapper.style.transform = `rotate(${state.rotations[idx]}deg)`; + } +} + +describe('rotate-pdf-page – page wrapper', () => { + let parentContainer: HTMLElement; + + beforeEach(() => { + parentContainer = document.createElement('div'); + parentContainer.id = 'page-thumbnails'; + document.body.appendChild(parentContainer); + }); + + it('should create a page wrapper with correct data attributes', () => { + const state = createTestState(3); + const wrapper = createPageWrapper(1, state); + expect(wrapper.dataset.pageIndex).toBe('0'); + expect(wrapper.dataset.pageNumber).toBe('1'); + }); + + it('should have left and right rotation buttons', () => { + const state = createTestState(1); + const wrapper = createPageWrapper(1, state); + const leftBtn = wrapper.querySelector('.rotate-left-btn'); + const rightBtn = wrapper.querySelector('.rotate-right-btn'); + expect(leftBtn).not.toBeNull(); + expect(rightBtn).not.toBeNull(); + }); + + it('should rotate right by 90° on right-button click', () => { + const state = createTestState(1); + const wrapper = createPageWrapper(1, state); + parentContainer.appendChild(wrapper); + + const rightBtn = wrapper.querySelector('.rotate-right-btn') as HTMLElement; + rightBtn.click(); + + expect(state.rotations[0]).toBe(90); + const tw = wrapper.querySelector('.thumbnail-wrapper') as HTMLElement; + expect(tw.style.transform).toBe('rotate(90deg)'); + }); + + it('should rotate left by -90° on left-button click', () => { + const state = createTestState(1); + const wrapper = createPageWrapper(1, state); + parentContainer.appendChild(wrapper); + + const leftBtn = wrapper.querySelector('.rotate-left-btn') as HTMLElement; + leftBtn.click(); + + expect(state.rotations[0]).toBe(-90); + const tw = wrapper.querySelector('.thumbnail-wrapper') as HTMLElement; + expect(tw.style.transform).toBe('rotate(-90deg)'); + }); + + it('should remain functional after multiple right-button clicks', () => { + const state = createTestState(1); + const wrapper = createPageWrapper(1, state); + parentContainer.appendChild(wrapper); + + const rightBtn = wrapper.querySelector('.rotate-right-btn') as HTMLElement; + + rightBtn.click(); + expect(state.rotations[0]).toBe(90); + + rightBtn.click(); + expect(state.rotations[0]).toBe(180); + + rightBtn.click(); + expect(state.rotations[0]).toBe(270); + + rightBtn.click(); + expect(state.rotations[0]).toBe(360); + + const tw = wrapper.querySelector('.thumbnail-wrapper') as HTMLElement; + expect(tw.style.transform).toBe('rotate(360deg)'); + }); + + it('should remain functional after multiple left-button clicks', () => { + const state = createTestState(1); + const wrapper = createPageWrapper(1, state); + parentContainer.appendChild(wrapper); + + const leftBtn = wrapper.querySelector('.rotate-left-btn') as HTMLElement; + + leftBtn.click(); + expect(state.rotations[0]).toBe(-90); + + leftBtn.click(); + expect(state.rotations[0]).toBe(-180); + + leftBtn.click(); + expect(state.rotations[0]).toBe(-270); + }); + + it('should allow alternating left and right clicks', () => { + const state = createTestState(1); + const wrapper = createPageWrapper(1, state); + parentContainer.appendChild(wrapper); + + const leftBtn = wrapper.querySelector('.rotate-left-btn') as HTMLElement; + const rightBtn = wrapper.querySelector('.rotate-right-btn') as HTMLElement; + + rightBtn.click(); + rightBtn.click(); + leftBtn.click(); + rightBtn.click(); + leftBtn.click(); + leftBtn.click(); + + expect(state.rotations[0]).toBe(0); + }); + + it('should independently rotate different pages', () => { + const state = createTestState(3); + const w1 = createPageWrapper(1, state); + const w2 = createPageWrapper(2, state); + const w3 = createPageWrapper(3, state); + parentContainer.append(w1, w2, w3); + + (w1.querySelector('.rotate-right-btn') as HTMLElement).click(); + (w2.querySelector('.rotate-left-btn') as HTMLElement).click(); + + expect(state.rotations).toEqual([90, -90, 0]); + }); + + it('should allow per-page rotation after a batch rotation', () => { + const state = createTestState(3); + const w1 = createPageWrapper(1, state); + const w2 = createPageWrapper(2, state); + const w3 = createPageWrapper(3, state); + parentContainer.append(w1, w2, w3); + + batchRotateAll(state, 90, [w1, w2, w3]); + expect(state.rotations).toEqual([90, 90, 90]); + + const rightBtn = w1.querySelector('.rotate-right-btn') as HTMLElement; + rightBtn.click(); + expect(state.rotations[0]).toBe(180); + + rightBtn.click(); + expect(state.rotations[0]).toBe(270); + + expect(state.rotations[1]).toBe(90); + expect(state.rotations[2]).toBe(90); + }); + + it('should allow per-page rotation after multiple batch rotations', () => { + const state = createTestState(2); + const w1 = createPageWrapper(1, state); + const w2 = createPageWrapper(2, state); + parentContainer.append(w1, w2); + + batchRotateAll(state, 90, [w1, w2]); + batchRotateAll(state, 90, [w1, w2]); + + expect(state.rotations).toEqual([180, 180]); + + (w1.querySelector('.rotate-left-btn') as HTMLElement).click(); + expect(state.rotations[0]).toBe(90); + + (w1.querySelector('.rotate-left-btn') as HTMLElement).click(); + expect(state.rotations[0]).toBe(0); + }); + + it('should allow batch rotation after per-page rotation', () => { + const state = createTestState(2); + const w1 = createPageWrapper(1, state); + const w2 = createPageWrapper(2, state); + parentContainer.append(w1, w2); + + (w1.querySelector('.rotate-right-btn') as HTMLElement).click(); + expect(state.rotations[0]).toBe(90); + + batchRotateAll(state, -90, [w1, w2]); + expect(state.rotations).toEqual([0, -90]); + + (w2.querySelector('.rotate-right-btn') as HTMLElement).click(); + expect(state.rotations[1]).toBe(0); + }); + + it('should apply correct CSS transform on each click', () => { + const state = createTestState(1); + const wrapper = createPageWrapper(1, state); + parentContainer.appendChild(wrapper); + + const tw = wrapper.querySelector('.thumbnail-wrapper') as HTMLElement; + const rightBtn = wrapper.querySelector('.rotate-right-btn') as HTMLElement; + + expect(tw.style.transform).toBe('rotate(0deg)'); + + rightBtn.click(); + expect(tw.style.transform).toBe('rotate(90deg)'); + + rightBtn.click(); + expect(tw.style.transform).toBe('rotate(180deg)'); + }); + + it('should apply initial rotation from state', () => { + const state = createTestState(2); + state.rotations[0] = 180; + state.rotations[1] = -90; + + const w1 = createPageWrapper(1, state); + const w2 = createPageWrapper(2, state); + + const tw1 = w1.querySelector('.thumbnail-wrapper') as HTMLElement; + const tw2 = w2.querySelector('.thumbnail-wrapper') as HTMLElement; + + expect(tw1.style.transform).toBe('rotate(180deg)'); + expect(tw2.style.transform).toBe('rotate(-90deg)'); + }); + + it('should stop click propagation on rotation buttons', () => { + const state = createTestState(1); + const wrapper = createPageWrapper(1, state); + parentContainer.appendChild(wrapper); + + const parentClickSpy = vi.fn(); + wrapper.addEventListener('click', parentClickSpy); + + const rightBtn = wrapper.querySelector('.rotate-right-btn') as HTMLElement; + rightBtn.click(); + + expect(parentClickSpy).not.toHaveBeenCalled(); + }); + + it('should have cursor-pointer class on rotation buttons', () => { + const state = createTestState(1); + const wrapper = createPageWrapper(1, state); + + const leftBtn = wrapper.querySelector('.rotate-left-btn') as HTMLElement; + const rightBtn = wrapper.querySelector('.rotate-right-btn') as HTMLElement; + + expect(leftBtn.classList.contains('cursor-pointer')).toBe(true); + expect(rightBtn.classList.contains('cursor-pointer')).toBe(true); + }); + + it('should handle rapid successive clicks without losing state', () => { + const state = createTestState(1); + const wrapper = createPageWrapper(1, state); + parentContainer.appendChild(wrapper); + + const rightBtn = wrapper.querySelector('.rotate-right-btn') as HTMLElement; + + for (let i = 0; i < 20; i++) { + rightBtn.click(); + } + + expect(state.rotations[0]).toBe(20 * 90); + }); + + it('should keep working even if button innerHTML is replaced (simulating createIcons)', () => { + const state = createTestState(1); + const wrapper = createPageWrapper(1, state); + parentContainer.appendChild(wrapper); + + const rightBtn = wrapper.querySelector('.rotate-right-btn') as HTMLElement; + + rightBtn.click(); + expect(state.rotations[0]).toBe(90); + + rightBtn.innerHTML = ''; + + rightBtn.click(); + expect(state.rotations[0]).toBe(180); + + rightBtn.click(); + expect(state.rotations[0]).toBe(270); + }); + + it('should keep working after replaceChild on icon element inside button', () => { + const state = createTestState(1); + const wrapper = createPageWrapper(1, state); + parentContainer.appendChild(wrapper); + + const leftBtn = wrapper.querySelector('.rotate-left-btn') as HTMLElement; + + leftBtn.click(); + expect(state.rotations[0]).toBe(-90); + + const oldChild = leftBtn.firstChild; + if (oldChild) { + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + svg.setAttribute('class', 'w-3 h-3'); + leftBtn.replaceChild(svg, oldChild); + } + + leftBtn.click(); + expect(state.rotations[0]).toBe(-180); + + leftBtn.click(); + expect(state.rotations[0]).toBe(-270); + }); +});