Skip to content

Share #395

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 13 commits into
base: beta
Choose a base branch
from
210 changes: 142 additions & 68 deletions app/[locale]/calibrate/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ export default function Page() {
const width = Number(widthInput) > 0 ? Number(widthInput) : 1;
const height = Number(heightInput) > 0 ? Number(heightInput) : 1;
const [isCalibrating, setIsCalibrating] = useState(true);
const [isFileDragging, setIsFileDragging] = useState(false);
const [fileLoadStatus, setFileLoadStatus] = useState<LoadStatusEnum>(
LoadStatusEnum.DEFAULT,
);
Expand Down Expand Up @@ -279,55 +280,52 @@ export default function Page() {
updateLocalSettings({ width: w });
}

// Set new file; reset file based state; and if available, load file based state from localStorage
function handleFileChange(e: ChangeEvent<HTMLInputElement>): void {
const { files } = e.target;
function openPatternFile(file: File): boolean {
if (!isValidFile(file)) {
return false;
}
setFile(file);
setFileLoadStatus(LoadStatusEnum.LOADING);
setRestoreTransforms(null);
setZoomedOut(false);
setMagnifying(false);
setMeasuring(false);
setPageCount(0);
setLayers({});
dispatchPatternScaleAction({ type: "set", scale: "1.00" });
const lineThicknessString = localStorage.getItem(
`lineThickness:${file.name}`,
);
if (lineThicknessString !== null) {
setLineThickness(Number(lineThicknessString));
} else {
setLineThickness(0);
}

if (files && files[0] && isValidFile(files[0])) {
setFile(files[0]);
setFileLoadStatus(LoadStatusEnum.LOADING);
setRestoreTransforms(null);
setZoomedOut(false);
setMagnifying(false);
setMeasuring(false);
setPageCount(0);
setLayers({});
dispatchPatternScaleAction({ type: "set", scale: "1.00" });
const lineThicknessString = localStorage.getItem(
`lineThickness:${files[0].name}`,
);
if (lineThicknessString !== null) {
setLineThickness(Number(lineThicknessString));
} else {
setLineThickness(0);
const key = `stitchSettings:${file.name ?? "default"}`;
const stitchSettingsString = localStorage.getItem(key);
if (stitchSettingsString !== null) {
const stitchSettings = JSON.parse(stitchSettingsString);
if (!stitchSettings.lineCount) {
// Old naming
stitchSettings.lineCount = stitchSettings.columnCount;
}

const key = `stitchSettings:${files[0].name ?? "default"}`;
const stitchSettingsString = localStorage.getItem(key);
if (stitchSettingsString !== null) {
const stitchSettings = JSON.parse(stitchSettingsString);
if (!stitchSettings.lineCount) {
// Old naming
stitchSettings.lineCount = stitchSettings.columnCount;
}
if (!stitchSettings.lineDirection) {
// For people who saved stitch settings before Line Direction was an option
stitchSettings.lineDirection = LineDirection.Column;
}
dispatchStitchSettings({ type: "set", stitchSettings });
} else {
dispatchStitchSettings({
type: "set",
stitchSettings: {
...defaultStitchSettings,
key,
},
});
if (!stitchSettings.lineDirection) {
// For people who saved stitch settings before Line Direction was an option
stitchSettings.lineDirection = LineDirection.Column;
}

calibrationCallback();
dispatchStitchSettings({ type: "set", stitchSettings });
} else {
dispatchStitchSettings({
type: "set",
stitchSettings: {
...defaultStitchSettings,
key,
},
});
}

calibrationCallback();
// If the user calibrated in full screen, try to go back into full screen after opening the file: some browsers pop users out of full screen when selecting a file
const expectedContext = localStorage.getItem("calibrationContext");
if (expectedContext !== null) {
Expand All @@ -338,8 +336,53 @@ export default function Page() {
}
} catch (e) {}
}
return true;
}

// Set new file; reset file based state; and if available, load file based state from localStorage
function handleFileChange(e: ChangeEvent<HTMLInputElement>): void {
const { files } = e.target;
if (files && files[0]) {
openPatternFile(files[0]);
}
}

const handleDragEnter = useCallback((e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
setIsFileDragging(true);
}, []);

const handleDragLeave = useCallback((e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
setIsFileDragging(false);
}, []);

const handleDragOver = useCallback((e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
// Set effect to copy (optional, but good practice for clarity)
e.dataTransfer.dropEffect = "copy";
setIsFileDragging(true); // Keep it highlighted during dragover
}, []);

const handleDrop = useCallback(
(e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
setIsFileDragging(false); // Reset dragging state

const files = e.dataTransfer.files;

if (files && files.length > 0) {
const file = files[0];
openPatternFile(file);
}
},
[openPatternFile],
);

function handlePointerDown(e: React.PointerEvent) {
resetIdle();

Expand Down Expand Up @@ -384,6 +427,34 @@ export default function Page() {
}

// EFFECTS
useEffect(() => {
console.log("checking for open file in URL parameters");
const params = new URL(location.href).searchParams;
const openFile = params.get("open");
const name = params.get("name") ?? "";
if (openFile !== null) {
console.log("Client: openFile found in URL parameters.");
fetch(openFile)
.then((response) => response.blob())
.then((blob) => {
const file = new File([blob], name, {
type: blob.type,
});
openPatternFile(file);
console.log("Client: Shared file loaded successfully.");
// Check for shared file URL in query parameters on initial load
if (window.history.replaceState) {
const url = new URL(window.location.href);
url.searchParams.delete("open");
url.searchParams.delete("name");
window.history.replaceState({ path: url.href }, "", url.href);
}
})
.catch((error) => {
console.error("Client: Error loading shared file:", error);
});
}
}, []);

// Allow the user to open the file from their file browser, e.g., "Open With"
useEffect(() => {
Expand All @@ -394,7 +465,7 @@ export default function Page() {
for (const handle of launchParams.files) {
if (handle.kind == "file") {
const file = await (handle as FileSystemFileHandle).getFile();
setFile(file);
openPatternFile(file);
return;
}
}
Expand Down Expand Up @@ -521,6 +592,10 @@ export default function Page() {
<main
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
ref={noZoomRefCallback}
className={`${menusHidden && "cursor-none"} ${isDarkTheme(displaySettings.theme) && "dark bg-black"} w-screen h-screen absolute overflow-hidden touch-none`}
>
Expand All @@ -529,28 +604,6 @@ export default function Page() {
handle={fullScreenHandle}
className="bg-white dark:bg-black transition-all duration-500 w-screen h-screen"
>
{showCalibrationAlert ? (
<div className="flex flex-col items-center gap-4 absolute left-1/4 top-1/2 -translate-y-1/2 w-1/2 bg-white dark:bg-black dark:text-white z-[150] p-4 rounded border-2 border-black dark:border-white">
<WarningIcon ariaLabel="warning" />
<p>{t("calibrationAlert")}</p>
<Button
className="flex items-center justify-center"
onClick={() => toggleFullScreen(fullScreenHandle)}
>
<span className="mr-1 -mt-1.5 w-4 h-4">
{fullScreenHandle.active ? (
<FullScreenIcon ariaLabel={t("fullscreen")} />
) : (
<FullScreenExitIcon ariaLabel={t("fullscreenExit")} />
)}
</span>
{fullScreenHandle.active
? t("fullscreenExit")
: t("fullscreen")}
</Button>
<p>{t("calibrationAlertContinue")}</p>
</div>
) : null}
<Modal open={errorMessage !== null}>
<ModalTitle>{g("error")}</ModalTitle>
<ModalContent>
Expand Down Expand Up @@ -683,7 +736,28 @@ export default function Page() {
patternScale={String(patternScaleFactor)}
/>
</MeasureCanvas>

{showCalibrationAlert ? (
<div className="flex flex-col items-center gap-4 absolute left-1/4 top-1/2 -translate-y-1/2 w-1/2 bg-white dark:bg-black opacity-80 dark:text-white p-4 rounded border-2 border-black dark:border-white pointer-events-none">
<WarningIcon ariaLabel="warning" />
<p>{t("calibrationAlert")}</p>
<Button
className="flex items-center justify-center pointer-events-auto"
onClick={() => toggleFullScreen(fullScreenHandle)}
>
<span className="mr-1 -mt-1.5 w-4 h-4">
{fullScreenHandle.active ? (
<FullScreenIcon ariaLabel={t("fullscreen")} />
) : (
<FullScreenExitIcon ariaLabel={t("fullscreenExit")} />
)}
</span>
{fullScreenHandle.active
? t("fullscreenExit")
: t("fullscreen")}
</Button>
<p>{t("calibrationAlertContinue")}</p>
</div>
) : null}
<menu
className={`absolute w-screen ${visible(!menusHidden)} ${menuStates.nav ? "top-0" : "-top-16"} pointer-events-none`}
>
Expand Down
15 changes: 14 additions & 1 deletion app/manifest.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export default function manifest() {
orientation: "landscape",
lang: "en-US",
dir: "ltr",
scope: "https://patternprojector.com",
scope: "/",
scope_extensions: [{ origin: "*.patternprojector.com" }],
prefer_related_applications: false,
launch_handler: {
Expand All @@ -44,5 +44,18 @@ export default function manifest() {
},
},
],
share_target: {
action: "/shared-target",
method: "POST",
enctype: "multipart/form-data",
params: {
files: [
{
name: "shared_file",
accept: ["application/pdf", "image/svg+xml"],
},
],
},
},
};
}
77 changes: 77 additions & 0 deletions app/sw.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { defaultCache } from "@serwist/next/browser";
import type { PrecacheEntry } from "@serwist/precaching";
import { installSerwist } from "@serwist/sw";
import { registerRoute } from "@serwist/routing";
import { NetworkOnly } from "@serwist/strategies";

declare const self: ServiceWorkerGlobalScope & {
// Change this attribute's name to your `injectionPoint`.
Expand All @@ -9,6 +11,81 @@ declare const self: ServiceWorkerGlobalScope & {
__SW_MANIFEST: (PrecacheEntry | string)[] | undefined;
};

registerRoute(
/shared-target/,
async ({ url, request, event }) => {
console.log(
`My Service Worker: Handling navigation request for ${url.pathname}`,
);
if (request.method === "POST" && url.pathname === "/shared-target") {
try {
const formData = await request.formData();
const sharedFile = formData.get("shared_file");

if (sharedFile instanceof File) {
const fileBuffer = await sharedFile.arrayBuffer();
// use the cache fo all requests
const cache = await caches.open("shared-file-cache");
console.log(
`Service Worker: Caching shared file ${sharedFile.name} with size ${sharedFile.size} bytes`,
);
// Store the file in the cache with a request to the shared-file endpoint
// This allows us to retrieve it later using the same URL
// The request URL is `/shared-file/` and the response is the file content
// with appropriate headers
await cache.put(
new Request(`/shared-file/`),
new Response(fileBuffer, {
headers: {
"Content-Type": sharedFile.type,
"Content-Length": sharedFile.size.toString(),
"Content-Disposition": `attachment; filename="${sharedFile.name}"`,
},
}),
);
const openFileUrl = new URL("/calibrate", self.location.origin);
openFileUrl.searchParams.set("name", sharedFile.name);
openFileUrl.searchParams.set("open", "/shared-file/");
return Response.redirect(openFileUrl, 303);
} else {
console.error(
"Service Worker: No file received or file is not an instance of File.",
);
return new Response("No file received for shared-file.", {
status: 400,
});
}
} catch (error) {
console.error("Service Worker: Error handling shared file:", error);
return new Response("Error processing shared file.", { status: 500 });
}
}
return new NetworkOnly().handle({ url, request, event });
},
"POST",
);

registerRoute(
({ url }) => url.pathname.startsWith("/shared-file/"),
async ({ url, request }) => {
console.log(
`My Service Worker: Handling request for shared file ${url.pathname}`,
);
const cache = await caches.open("shared-file-cache");
const cachedResponse = await cache.match(request);
if (cachedResponse) {
console.log(
`My Service Worker: Found cached response for ${url.pathname}`,
);
return cachedResponse;
} else {
console.log(`My Service Worker: No cached response for ${url.pathname}`);
return new Response("Shared file not found in cache.", { status: 404 });
}
},
"GET",
);

installSerwist({
precacheEntries: self.__SW_MANIFEST,
skipWaiting: true,
Expand Down