fix: rotation functionality for PDF page and add corresponding tests

This commit is contained in:
alam00000
2026-03-09 19:56:50 +05:30
parent 1d68691331
commit f57ea089e9
5 changed files with 395 additions and 12 deletions

View File

@@ -261,6 +261,9 @@ async function generateI18nPages() {
if (processed % 10 === 0 || processed === total) { if (processed % 10 === 0 || processed === total) {
console.log(` Progress: ${processed}/${total} pages`); console.log(` Progress: ${processed}/${total} pages`);
} }
// Clean up JSDOM instances
await new Promise((resolve) => setImmediate(resolve));
} }
updateEnglishFile(filePath, originalContent); updateEnglishFile(filePath, originalContent);

View File

@@ -225,7 +225,7 @@ input[type='file']::file-selector-button {
color: #39a0ed; color: #39a0ed;
} }
.page-thumbnail, #page-organizer .page-thumbnail,
#file-list > li { #file-list > li {
cursor: grab; cursor: grab;
} }

View File

@@ -496,9 +496,7 @@ export async function extractPageModel(
page: pdfjsLib.PDFPageProxy, page: pdfjsLib.PDFPageProxy,
viewport: pdfjsLib.PageViewport viewport: pdfjsLib.PageViewport
): Promise<ComparePageModel> { ): Promise<ComparePageModel> {
const textContent = await page.getTextContent({ const textContent = await page.getTextContent();
disableCombineTextItems: true,
});
const styles = textContent.styles ?? {}; const styles = textContent.styles ?? {};
const rawItems = sortCompareTextItems( const rawItems = sortCompareTextItems(
textContent.items textContent.items

View File

@@ -98,38 +98,40 @@ function createPageWrapper(
const rotateLeftBtn = document.createElement('button'); const rotateLeftBtn = document.createElement('button');
rotateLeftBtn.className = 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 = '<i data-lucide="rotate-ccw" class="w-3 h-3"></i>'; rotateLeftBtn.innerHTML = '<i data-lucide="rotate-ccw" class="w-3 h-3"></i>';
rotateLeftBtn.onclick = function (e) { rotateLeftBtn.addEventListener('click', function (e) {
e.stopPropagation(); e.stopPropagation();
e.preventDefault();
pageState.rotations[pageIndex] = pageState.rotations[pageIndex] - 90; pageState.rotations[pageIndex] = pageState.rotations[pageIndex] - 90;
const wrapper = container.querySelector( const wrapper = container.querySelector(
'.thumbnail-wrapper' '.thumbnail-wrapper'
) as HTMLElement; ) as HTMLElement;
if (wrapper) if (wrapper)
wrapper.style.transform = `rotate(${pageState.rotations[pageIndex]}deg)`; wrapper.style.transform = `rotate(${pageState.rotations[pageIndex]}deg)`;
}; });
const rotateRightBtn = document.createElement('button'); const rotateRightBtn = document.createElement('button');
rotateRightBtn.className = 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 = '<i data-lucide="rotate-cw" class="w-3 h-3"></i>'; rotateRightBtn.innerHTML = '<i data-lucide="rotate-cw" class="w-3 h-3"></i>';
rotateRightBtn.onclick = function (e) { rotateRightBtn.addEventListener('click', function (e) {
e.stopPropagation(); e.stopPropagation();
e.preventDefault();
pageState.rotations[pageIndex] = pageState.rotations[pageIndex] + 90; pageState.rotations[pageIndex] = pageState.rotations[pageIndex] + 90;
const wrapper = container.querySelector( const wrapper = container.querySelector(
'.thumbnail-wrapper' '.thumbnail-wrapper'
) as HTMLElement; ) as HTMLElement;
if (wrapper) if (wrapper)
wrapper.style.transform = `rotate(${pageState.rotations[pageIndex]}deg)`; wrapper.style.transform = `rotate(${pageState.rotations[pageIndex]}deg)`;
}; });
controls.append(rotateLeftBtn, rotateRightBtn); controls.append(rotateLeftBtn, rotateRightBtn);
container.appendChild(controls); container.appendChild(controls);
// Re-create icons for the new element // Re-create icons scoped to this container only
setTimeout(function () { setTimeout(function () {
createIcons({ icons }); createIcons({ icons, nameAttr: 'data-lucide', attrs: {} });
}, 0); }, 0);
return container; return container;

View File

@@ -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 = '<svg class="w-3 h-3"></svg>';
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);
});
});