Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
- Change default disabled text area colour to ligher grey on light mode (#7269)
- Implement a limit on the file size rendered by the submission viewer (#7273)
- Add an option to retain old grading data when recollecting graded submissions (#7256)
- Added zoom and rotate functionality to PDF viewer (#7306)

### 🐛 Bug fixes

Expand Down
102 changes: 83 additions & 19 deletions app/assets/javascripts/Components/Result/pdf_viewer.jsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
import React from "react";
import {SingleSelectDropDown} from "../../DropDownMenu/SingleSelectDropDown";

export class PDFViewer extends React.PureComponent {
constructor(props) {
super(props);
this.pdfContainer = React.createRef();
this.state = {
zoom: "page-width",
rotation: 0, // NOTE: this is in degrees
};
}

componentDidMount() {
Expand All @@ -18,6 +23,8 @@ export class PDFViewer extends React.PureComponent {
if (this.props.resultView) {
this.eventBus.on("pagesinit", this.ready_annotations);
this.eventBus.on("pagesloaded", this.refresh_annotations);
} else {
this.eventBus.on("pagesloaded", this.update_pdf_view);
}

if (this.props.url) {
Expand All @@ -31,6 +38,8 @@ export class PDFViewer extends React.PureComponent {
} else {
if (this.props.resultView) {
this.refresh_annotations();
} else {
this.update_pdf_view();
}
}
}
Expand All @@ -44,7 +53,6 @@ export class PDFViewer extends React.PureComponent {
ready_annotations = () => {
annotation_type = ANNOTATION_TYPES.PDF;

this.pdfViewer.currentScaleValue = "page-width";
window.annotation_manager = new PdfAnnotationManager(!this.props.released_to_students);
window.annotation_manager.resetAngle();
this.annotation_manager = window.annotation_manager;
Expand All @@ -61,15 +69,30 @@ export class PDFViewer extends React.PureComponent {
window.pdfViewer = undefined;
}

update_pdf_view = () => {
this.pdfViewer.currentScaleValue = this.state.zoom;
this.pdfViewer.pagesRotation = this.state.rotation;
};

refresh_annotations = () => {
$(".annotation_holder").remove();
this.pdfViewer.currentScaleValue = "page-width";
this.update_pdf_view();
this.props.annotations.forEach(this.display_annotation);
if (!!this.props.annotationFocus) {
document.getElementById("annotation_holder_" + this.props.annotationFocus).scrollIntoView();
}
};

rotate = () => {
if (this.props.resultView) {
annotation_manager.rotateClockwise90();
}

this.setState(({rotation}) => ({
rotation: (rotation + 90) % 360,
}));
};

display_annotation = annotation => {
if (annotation.x_range === undefined || annotation.y_range === undefined) {
return;
Expand Down Expand Up @@ -101,31 +124,72 @@ export class PDFViewer extends React.PureComponent {
);
};

rotate = () => {
annotation_manager.rotateClockwise90();
this.pdfViewer.rotatePages(90);
getZoomValuesToDisplayName = () => {
// 25-200 in increments of 25
const zoomLevels = Array.from({length: (200 - 25) / 25 + 1}, (_, i) =>
((i * 25 + 25) / 100).toFixed(2)
);

const valueToDisplayName = zoomLevels.reduce(
(acc, value) => {
acc[value] = `${(value * 100).toFixed(0)} %`;
return acc;
},
{"page-width": I18n.t("results.fit_to_page_width")}
);

return valueToDisplayName;
};

render() {
const cursor = this.props.released_to_students ? "default" : "crosshair";
const userSelect = this.props.released_to_students ? "default" : "none";
const zoomValuesToDisplayName = this.getZoomValuesToDisplayName();

return (
<div className="pdfContainerParent">
<div
id="pdfContainer"
className="pdfContainer"
style={{cursor, userSelect}}
ref={this.pdfContainer}
>
<div id="viewer" className="pdfViewer" />
<React.Fragment>
<div className="toolbar">
<div className="toolbar-actions">
{I18n.t("results.current_rotation", {rotation: this.state.rotation})}
<button onClick={this.rotate} className={"inline-button"}>
{I18n.t("results.rotate_image")}
</button>
<span style={{marginLeft: "7px"}}>{I18n.t("results.zoom")}</span>
<SingleSelectDropDown
valueToDisplayName={zoomValuesToDisplayName}
options={Object.keys(zoomValuesToDisplayName)}
selected={this.state.zoom}
dropdownStyle={{
minWidth: "auto",
width: "fit-content",
marginLeft: "5px",
verticalAlign: "middle",
}}
selectionStyle={{width: "90px", marginRight: "0px"}}
hideXMark={true}
onSelect={selection => {
this.setState({zoom: selection});
}}
/>
</div>
</div>
<div className="pdfContainerParent">
<div
key="sel_box"
id="sel_box"
className="annotation-holder-active"
style={{display: "none"}}
/>
id="pdfContainer"
className="pdfContainer"
style={{cursor, userSelect}}
ref={this.pdfContainer}
>
<div id="viewer" className="pdfViewer" />
<div
key="sel_box"
id="sel_box"
className="annotation-holder-active"
style={{display: "none"}}
/>
</div>
</div>
</div>
</React.Fragment>
);
}
}
116 changes: 116 additions & 0 deletions app/assets/javascripts/Components/__tests__/pdf_viewer.test.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import React from "react";
import {render, screen, fireEvent} from "@testing-library/react";
import {PDFViewer} from "../Result/pdf_viewer";

describe("PDFViewer", () => {
let mockPdfViewer;
let mockAnnotationManager;

beforeEach(() => {
mockPdfViewer = {
setDocument: jest.fn(),
pagesRotation: 0,
currentScaleValue: "page-width",
};

mockAnnotationManager = {
rotateClockwise90: jest.fn(),
};

global.pdfjsViewer = {
EventBus: class {
on = jest.fn();
},
PDFViewer: jest.fn(() => mockPdfViewer),
};

global.annotation_manager = mockAnnotationManager;

render(<PDFViewer resultView={true} annotations={[]} />);
});

afterEach(() => {
jest.restoreAllMocks();
delete global.pdfjsViewer;
delete global.annotation_manager;
});

describe("rotation", () => {
let rotateButton;

beforeEach(() => {
rotateButton = screen.getByText(I18n.t("results.rotate_image"));
});

it("initially has a rotation of 0", async () => {
expect(mockPdfViewer.pagesRotation).toBe(0);
});

it("rotates to 90 degrees when rotate button is clicked once", () => {
fireEvent.click(rotateButton);

expect(mockAnnotationManager.rotateClockwise90).toHaveBeenCalledTimes(1);
expect(mockPdfViewer.pagesRotation).toBe(90);
});

it("rotates back to 0 degrees when rotate button is clicked four times", () => {
for (let i = 0; i < 4; i++) {
fireEvent.click(rotateButton);
}

expect(mockAnnotationManager.rotateClockwise90).toHaveBeenCalledTimes(4);
expect(mockPdfViewer.pagesRotation).toBe(0);
});
});

describe("zoom", () => {
it("has default zoom 'page-width' on initial render", () => {
expect(mockPdfViewer.currentScaleValue).toBe("page-width");
});

it("updates zoom to 100% (1.0) when the option is selected from dropdown", () => {
const dropdown = screen.getByTestId("dropdown");
fireEvent.click(dropdown);

const option100 = screen.getByText("100 %");
fireEvent.click(option100);

expect(mockPdfViewer.currentScaleValue).toBe("1.00");
});

it("updates zoom to 75% (0.75) when the option is selected from dropdown", () => {
const dropdown = screen.getByTestId("dropdown");
fireEvent.click(dropdown);

const option110 = screen.getByText("75 %");
fireEvent.click(option110);

expect(mockPdfViewer.currentScaleValue).toBe("0.75");
});

it("updates zoom to 125% (1.25) when the option is selected from dropdown", () => {
const dropdown = screen.getByTestId("dropdown");
fireEvent.click(dropdown);

const option120 = screen.getByText("125 %");
fireEvent.click(option120);

expect(mockPdfViewer.currentScaleValue).toBe("1.25");
});

it("resets zoom to 'page-width' when the option is selected after selecting another zoom", () => {
// set some arbitrary zoom first
const dropdown = screen.getByTestId("dropdown");
fireEvent.click(dropdown);
const option120 = screen.getByText("125 %");
fireEvent.click(option120);

// now put it back to page width
fireEvent.click(dropdown);
const fitToPageWidthOption = screen.getByText(I18n.t("results.fit_to_page_width"));
fireEvent.click(fitToPageWidthOption);

expect(mockPdfViewer.currentScaleValue).toBe("page-width");
});
});
});
26 changes: 14 additions & 12 deletions app/assets/javascripts/DropDownMenu/SingleSelectDropDown.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -72,28 +72,30 @@ export class SingleSelectDropDown extends React.Component {
return (
<div
className="dropdown single-select-dropdown"
style={this.props.dropdownStyle}
onClick={() => this.setState({expanded: !this.state.expanded})}
onBlur={() => this.setState({expanded: false})}
tabIndex={-1}
data-testid={"dropdown"}
>
<a data-testid={"selection"}>
<a data-testid={"selection"} style={this.props.selectionStyle}>
{this.props.valueToDisplayName != null
? this.props.valueToDisplayName[this.props.selected]
: this.props.selected}
</a>
{this.renderArrow()}
<div
className="float-right"
onClick={e => {
e.preventDefault();
this.onSelect(e, this.props.defaultValue);
}}
data-testid={"reset-dropdown-selection"}
>
<FontAwesomeIcon icon="fa-solid fa-xmark" className={"x-mark"} />
</div>

{!this.props.hideXMark && (
<div
className="float-right"
onClick={e => {
e.preventDefault();
this.onSelect(e, this.props.defaultValue);
}}
data-testid={"reset-dropdown-selection"}
>
<FontAwesomeIcon icon="fa-solid fa-xmark" className={"x-mark"} />
</div>
)}
{expanded && this.renderDropdown(options, selected, expanded, disabled)}
</div>
);
Expand Down
2 changes: 1 addition & 1 deletion app/assets/stylesheets/common/codeviewer.scss
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
.toolbar-actions {
background-color: $background-main;
font: 0.825em $fonts;
padding: 5px 0;
padding: 5px;
text-align: right;

a {
Expand Down
2 changes: 2 additions & 0 deletions app/assets/stylesheets/common/pdfjs_custom.scss
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
.pdfContainerParent {
position: relative;
overflow: auto;
height: 100%;
}

.pdfContainer {
Expand Down
2 changes: 2 additions & 0 deletions config/locales/views/results/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ en:
ascending: Ascending
descending: Descending
text_box_placeholder: Search text
fit_to_page_width: Fit to page width
fullscreen_enter: Fullscreen
fullscreen_exit: Leave fullscreen
keybinding:
Expand Down Expand Up @@ -91,5 +92,6 @@ en:
view_group_repo: View group repository
view_token_submit: Please enter the unique token provided by your instructor to view the results for this assignment.
your_mark: Your Mark
zoom: 'Zoom:'
zoom_in_image: Zoom in
zoom_out_image: Zoom out