diff --git a/app/[locale]/calibrate/page.tsx b/app/[locale]/calibrate/page.tsx index cb18ef2..57440c2 100644 --- a/app/[locale]/calibrate/page.tsx +++ b/app/[locale]/calibrate/page.tsx @@ -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.DEFAULT, ); @@ -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): 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) { @@ -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): void { + const { files } = e.target; + if (files && files[0]) { + openPatternFile(files[0]); + } + } + + const handleDragEnter = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsFileDragging(true); + }, []); + + const handleDragLeave = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsFileDragging(false); + }, []); + + const handleDragOver = useCallback((e: React.DragEvent) => { + 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) => { + 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(); @@ -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(() => { @@ -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; } } @@ -521,6 +592,10 @@ export default function Page() {
@@ -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 ? ( -
- -

{t("calibrationAlert")}

- -

{t("calibrationAlertContinue")}

-
- ) : null} {g("error")} @@ -683,7 +736,28 @@ export default function Page() { patternScale={String(patternScaleFactor)} /> - + {showCalibrationAlert ? ( +
+ +

{t("calibrationAlert")}

+ +

{t("calibrationAlertContinue")}

+
+ ) : null} diff --git a/app/manifest.js b/app/manifest.js index cac5e99..d883013 100644 --- a/app/manifest.js +++ b/app/manifest.js @@ -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: { @@ -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"], + }, + ], + }, + }, }; } diff --git a/app/sw.ts b/app/sw.ts index 5cfac5d..3824bca 100644 --- a/app/sw.ts +++ b/app/sw.ts @@ -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`. @@ -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,