Skip to content

Commit

Permalink
Improved shortcuts and button (#9)
Browse files Browse the repository at this point in the history
* Improved keyboard shortcuts & new button animation

- Improved keyboard shortcuts
  * Now multiple keys can be specified for handling a command
  * Added more commands
- New button animation
  * The new animation for the circular button edits the border radius
  * When an item opens a new toolbar tab, it'll be circular
- Added version in the "Licenses tab"

* Added PWA promotion card

* Remove logging
  • Loading branch information
dinoosauro authored Apr 23, 2024
1 parent 9b8d4d1 commit 91a62eb
Show file tree
Hide file tree
Showing 18 changed files with 303 additions and 145 deletions.
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,12 @@ You can add keyboard shortcuts for:
- Write text
- Enable/disable eraser
- Stop every option
- Enable/disable thumbnail view
- Enable/disable fullscreen
- Show settings
- Go to next page
- Go to previous page
- Export as an image

![Keyboard shortcuts tab](./readme-images/KeyboardShortcutsTab.jpg)

Expand Down
2 changes: 1 addition & 1 deletion index.html
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
};
registerServiceWorker();
}
let appVersion = "2.0.3";
let appVersion = "2.0.4";
fetch("./pdfpointer-updatecode", { cache: "no-store" }).then((res) => res.text().then((text) => { if (text.replace("\n", "") !== appVersion) if (confirm(`There's a new version of pdf-pointer. Do you want to update? [${appVersion} --> ${text.replace("\n", "")}]`)) { caches.delete("pdfpointer-cache"); location.reload(true); } }).catch((e) => { console.error(e) })).catch((e) => console.error(e)); // Check if the application code is the same as the current application version and, if not, ask the user to update
</script>

Expand Down
2 changes: 1 addition & 1 deletion public/pdfpointer-updatecode
Original file line number Diff line number Diff line change
@@ -1 +1 @@
2.0.3
2.0.4
56 changes: 38 additions & 18 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,11 @@ import Lang from "./Scripts/LanguageTranslations";
import BackgroundManager from "./Scripts/BackgroundManager";
PDFJS.GlobalWorkerOptions.workerSrc = pdfjsWorker;
interface State {
PDFObj: PDFDocumentProxy | null
PDFObj: PDFDocumentProxy | null,
hideTab?: boolean
}
function app() {
let installationPrompt: any;
export default function App() {
let [CurrentState, UpdateState] = useState<State>({ PDFObj: null });
useEffect(() => {
let theme = JSON.parse(localStorage.getItem("PDFPointer-CurrentTheme") ?? "[]") as CustomProp["lists"];
Expand All @@ -28,6 +30,10 @@ function app() {
getNewState(await launchParams.files[0].getFile());
});
}
window.addEventListener('beforeinstallprompt', (event) => { // Capture the request to install the PWA so that it can be displayed when the button is clicked
event.preventDefault();
installationPrompt = event;
});
}, [])
async function getNewState(file: File) {
let doc = PDFJS.getDocument(await file.arrayBuffer());
Expand All @@ -38,21 +44,36 @@ function app() {
return <>
<Header></Header><br></br>
{CurrentState.PDFObj === null ? <>
<Card>
<h2>{Lang("Choose file")}</h2>
<div className="center" style={{ width: "100%" }}>
<DynamicImg id="laptop" width={200}></DynamicImg><br></br>
</div>
<i>{Lang("Don't worry. Everything will stay on your device.")}</i><br></br><br></br>
<button onClick={() => { // Get the PDF file
let input = document.createElement("input");
input.type = "file";
input.onchange = async () => {
input.files !== null && getNewState(input.files[0]);
}
input.click();
}}>{Lang("Choose file")}</button>
</Card>
<div className={!CurrentState.hideTab && !window.matchMedia('(display-mode: standalone)').matches ? "doubleFlex" : undefined}>
<Card>
<h2>{Lang("Choose file")}</h2>
<div className="center" style={{ width: "100%" }}>
<DynamicImg id="laptop" width={200}></DynamicImg><br></br>
</div>
<i>{Lang("Don't worry. Everything will stay on your device.")}</i><br></br><br></br>
<button onClick={() => { // Get the PDF file
let input = document.createElement("input");
input.type = "file";
input.onchange = () => {
input.files !== null && getNewState(input.files[0]);
}
input.click();
}}>{Lang("Choose file")}</button>
</Card>
{!CurrentState.hideTab && !window.matchMedia('(display-mode: standalone)').matches && <Card>
<h2>{Lang("Install as a web app")}</h2>
<div className="center" style={{ width: "100%" }}>
<DynamicImg id="app" width={200}></DynamicImg><br></br>
</div>
<i>{Lang("Install PDFPointer as an app for offline use and better integration with the OS.")}</i><br></br><br></br>
<button onClick={() => {
installationPrompt.prompt();
installationPrompt.userChoice.then((choice: { outcome: string }) => {
if (choice.outcome === "accepted") UpdateState(prevState => { return { ...prevState, hideTab: true } })
});
}}>{Lang("Install app")}</button>
</Card>}
</div>
</> : <>
<Card>
<PdfObj pdfObj={CurrentState.PDFObj}></PdfObj>
Expand All @@ -61,4 +82,3 @@ function app() {
}
</>
}
export default app;
15 changes: 7 additions & 8 deletions src/Components/CircularButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ interface Props {
disableOpacity?: boolean,
dataTest?: string,
hint?: string,
doesChangeSection?: string
btnIdentifier?: string
}
import { DynamicImg } from "./DynamicImg";
import getImg from "../Scripts/ImgReturn";
Expand All @@ -24,18 +24,19 @@ import RerenderButtons from "../Scripts/RerenderButtons";
* @param dropdownCallback call this function after the opacity transition has been done
* @param dataTest add a "data-test" attribute to the circular button
* @param hint add an hint that'll be displayed when hovering the button
* @param btnIdentifier an identifier of the action that the button does. If provided, the button will be inserted in the RerenderButtons map, permitting to rerender is state (clicked or not) also from other functions.
* @returns the circular button ReactNode
*/
export default function CircularButton({ imgId, click, marginLeft, marginRight, enabledSwitch, dropdownCallback, dataTest, hint, doesChangeSection }: Props) {
export default function CircularButton({ imgId, click, marginLeft, marginRight, enabledSwitch, dropdownCallback, dataTest, hint, btnIdentifier }: Props) {
let [enabled, changeEnabled] = useState(false); // If the button is enabled
let isSelectable = getImg(`${imgId}_fill`) !== ""; // Check if there's a specific icon for the clicked button
let hintDiv = useRef<HTMLDivElement>(null);
let mainDiv = useRef<HTMLDivElement>(null);
useEffect(() => { // If the item is one that can be changed by keyboard settings, add it to the list of buttons that can be re-rendered
doesChangeSection && mainDiv.current && RerenderButtons.set(changeEnabled, doesChangeSection);
btnIdentifier && mainDiv.current && RerenderButtons.set(changeEnabled, btnIdentifier);
}, []);
useEffect(() => { // Update the button status as selected if it's the first of the list. This is done for the buttons that can be triggered by keyboard shortcuts since, otherwise, the user would automatically toggle it. Also, the eraser button will never be the first, so this must not be considered
doesChangeSection && doesChangeSection !== "erase" && changeEnabled(document.querySelector(".toolbar .opacityHoverContainer") === mainDiv.current);
btnIdentifier && btnIdentifier !== "erase" && btnIdentifier !== "thumbnail" && changeEnabled(document.querySelector(".toolbar .opacityHoverContainer") === mainDiv.current);
})
return <div>
<div role="button" ref={mainDiv} className={`opacityHoverContainer circularBtn${enabled && isSelectable ? " circularSelected" : ""}`} onClick={async (e) => {
Expand All @@ -44,16 +45,14 @@ export default function CircularButton({ imgId, click, marginLeft, marginRight,
if (toolbar && mainDiv.current) {
document.body.style.setProperty("--showhint", "0"); // Avoid showing hints when changing section
// Create a simple animation for the clicked button
mainDiv.current.style.transform = "scale(1.5)";
mainDiv.current.style.opacity = "0.2";
mainDiv.current.style.opacity = "0.4";
await new Promise<void>((resolve) => { setTimeout(() => { resolve() }, 190) });
mainDiv.current.style.transform = "";
mainDiv.current.style.opacity = "1";
setTimeout(() => document.body.style.setProperty("--showhint", "1"), 300); // Show again the hints
dropdownCallback && dropdownCallback();
}
enabledSwitch && isSelectable && mainDiv.current && changeEnabled(prevState => !prevState);
}} style={{ marginLeft: marginLeft, marginRight: marginRight }} data-test={dataTest} onMouseEnter={(e) => {
}} style={{ marginLeft: marginLeft, marginRight: marginRight, borderRadius: enabled && isSelectable ? "50%" : undefined }} data-test={dataTest} onMouseEnter={(e) => {
if (hintDiv.current) hintDiv.current.style.left = `${e.clientX}px`; // Add a fix for the placement of the hint if the bar needs to be scrolled
}}>
<DynamicImg id={`${imgId}${enabled && isSelectable ? "_fill" : ""}`} width={32}></DynamicImg>
Expand Down
4 changes: 2 additions & 2 deletions src/Components/DynamicImg.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
interface Props {
id: string, // The icon ID
width?: number,
staticColor?: string
staticColor?: string,
}
/**
* Create a Map that'll store the Image element and the string with its ID, so that each icon can be re-generated when the user changes the accent color
Expand All @@ -16,7 +16,7 @@ import ImgRef from "../Scripts/ImgReturn";
* @param staticColor the specific color to apply
* @returns the image ReactNode
*/
export function DynamicImg({ id, width = 24, staticColor }: Props) {
export function DynamicImg({ id, width = 24, staticColor, }: Props) {
let ref = useRef<HTMLImageElement>(null);
useEffect(() => { ref.current && !staticColor && imageStore.set(ref.current, id) }, []) // If the color is the default one, add it to the Map
return <img ref={ref} src={URL.createObjectURL(new Blob([ImgRef(id, staticColor)], { type: "image/svg+xml" }))} width={width} height={width}></img>
Expand Down
71 changes: 71 additions & 0 deletions src/Components/ExportDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { useRef } from "react";
import Lang from "../Scripts/LanguageTranslations";
import ImageExport from "../Scripts/Export";
import * as PDFJS from "pdfjs-dist";

/**
* The custom values for the PDF exportation as image
*/
let exportValue = {
img: "png",
pages: "",
annotations: true,
scale: 2,
zip: false,
quality: 0.85,
filter: ""
}

interface Props {
pdfObj: PDFJS.PDFDocumentProxy
}
/**
* Export the PDF as a group of images. This ReactNode contains the options for that exportation process.
* @param pdfObj the PDF object that'll be used for exporting things
* @returns the Export dialog ReactNode
*/
export default function ExportDialog({ pdfObj }: Props) {
let exportButton = useRef<HTMLButtonElement>(null);

return <div>
<label>{Lang("Export image in the")} <select defaultValue={exportValue.img} onChange={(e) => exportValue.img = (e.target as HTMLSelectElement).value}>
<option value={"jpeg"}>JPG</option>
<option value={"png"}>PNG</option>
{document.createElement("canvas").toDataURL("image/webp").startsWith("data:image/webp") && <option value={"webp"}>WEBP</option>}
</select> {Lang("format")}</label><br></br><br></br>
<label>{Lang("Write the number of pages to export:")}</label><br></br>
<i style={{ fontSize: "0.75em" }}>{Lang(`Separate pages with a comma, or add multiple pages with a dash: "1-5,7"`)}</i><br></br>
<input style={{ marginTop: "10px" }} type="text" defaultValue={exportValue.pages} onInput={(e) => {
exportValue.pages = (e.target as HTMLInputElement).value;
if (exportButton.current) exportButton.current.disabled = !/^[0-9,-]*$/.test(exportValue.pages);
}}></input><br></br><br></br>
<label>{Lang("Choose the size of the output image:")}</label><br></br>
<input type="range" min={0.5} max={8} step={0.01} defaultValue={exportValue.scale} onChange={(e) => { exportValue.scale = parseFloat((e.target as HTMLInputElement).value) }}></input><br></br><br></br>
<label>{Lang("Choose the quality of the output image:")}</label><br></br>
<input type="range" min={0.01} max={1} step={0.01} defaultValue={exportValue.quality} onChange={(e) => { exportValue.quality = parseFloat((e.target as HTMLInputElement).value) }}></input><br></br><br></br>
<div style={{ display: "flex", alignItems: "center" }}>
<input type="checkbox" defaultChecked={exportValue.annotations} onChange={e => exportValue.annotations = (e.target as HTMLInputElement).checked}></input><label>{Lang("Export also annotations")}</label></div><br></br>
{document.createElement("canvas").getContext("2d")?.filter !== undefined && <><div style={{ display: "flex", alignItems: "center" }}>
<input type="checkbox" defaultChecked={exportValue.filter !== ""} onChange={e => {
let getFilterCanvas = Array.from(document.querySelectorAll("canvas")).filter(e => e.style.filter !== "");
if ((e.target as HTMLInputElement).checked && getFilterCanvas.length !== 0) { exportValue.filter = getFilterCanvas[0].style.filter; } else exportValue.filter = ""
}}></input><label>{Lang("Apply filters to exported image")}</label></div><br></br>
</>}
<div style={{ display: "flex", alignItems: "center" }}>
<input type="checkbox" defaultChecked={exportValue.zip} onChange={e => exportValue.zip = (e.target as HTMLInputElement).checked}></input><label>{Lang("Save output as a .zip file")}</label></div><br></br><br></br>
<button ref={exportButton} onClick={async () => {
let handle;
try { // If the user doesn't want to save the file as ZIP, and the File System API is supported, use the "showDirectoryPicker" method
handle = window.showDirectoryPicker !== undefined && !exportValue.zip ? await window.showDirectoryPicker({ mode: "readwrite", id: "PDFPointer-PDFExportFolder" }) : undefined;
} catch (ex) {
console.warn({
type: "RejectedPicker",
desc: "The user rejected the selection of a folder. Fallback to link downloads.",
gravity: 0,
ex: ex
})
}
ImageExport({ imgType: exportValue.img, pages: exportValue.pages, getAnnotations: exportValue.annotations, pdfObj: pdfObj, scale: exportValue.scale, useZip: exportValue.zip, quality: exportValue.quality, handle: handle, filter: exportValue.filter })
}}>{Lang("Export images")}</button>
</div>
}
6 changes: 4 additions & 2 deletions src/Components/GetThumbnail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { DynamicImg } from "./DynamicImg";
interface Props {
PDFObj: PDFJS.PDFDocumentProxy, // The PDF Object from PDF.JS library
pageListener: (e: number) => void, // The event that'll be called for changing PDF page
closeEvent: () => void
}
interface CanvasContainer {
dom: HTMLCanvasElement | null,
Expand All @@ -17,9 +18,10 @@ let canvasContainer: CanvasContainer[] = [];
* Render all the PDF pages for the thumbnail
* @param PDFObj the PDF.JS object
* @param pageListener the function to call to move to a specific page
* @param closeEvent the event to call to close the thumbnail
* @returns the main thumbnail ReactNode
*/
export default function Thumbnail({ PDFObj, pageListener }: Props) {
export default function Thumbnail({ PDFObj, pageListener, closeEvent }: Props) {
let [pages, updatePages] = useState({
current: 0, // The next thumbnail to render
nextSuggested: true // If it's time to render more pages (–> if the user has scrolled a lot of the div)
Expand Down Expand Up @@ -77,7 +79,7 @@ export default function Thumbnail({ PDFObj, pageListener }: Props) {
let percentage = Math.round((container.scrollTop / (container.scrollHeight - container.offsetHeight)) * 100); // Get the percentage of scroll
if (percentage > 80) updatePages(prevState => { return { ...prevState, nextSuggested: true } })
}}>
<div style={{ position: "sticky", top: "15px", left: "25px", padding: "10px", backgroundColor: "var(--firststruct)", borderRadius: "8px", width: "24px", height: "24px" }} className="simplePointer" onClick={() => (document.querySelector("[data-test=ThumbnailEnabler]") as HTMLDivElement).click()}>
<div style={{ position: "sticky", top: "15px", left: "25px", padding: "10px", backgroundColor: "var(--firststruct)", borderRadius: "8px", width: "24px", height: "24px" }} className="simplePointer" onClick={closeEvent}>
<div style={{ height: "24px", width: "24px" }}><DynamicImg id="minimize" width={24}></DynamicImg></div>
</div>
{canvasContainer.filter(e => e.dom !== null).map(e => <ThumbnailContainer key={`PDFPageThumbnail-${e.page}`} pageNumber={e.page + 1} canvas={e.dom}></ThumbnailContainer>)}
Expand Down
Loading

0 comments on commit 91a62eb

Please sign in to comment.