JavaScript Development Space

Creating a Next.js Image Editor with glfx.js

1 April 202519 min read
Build a Powerful Image Editor with Next.js and glfx.js

Modern web applications often require real-time image processing capabilities. With Next.js for performance and glfx.js for GPU-accelerated image effects, we can create a fast and responsive image editor with various filters and transformations.

Demo | Source Code

In this guide, we’ll cover:

  • Setting up a Next.js project
  • Integrating glfx.js for real-time image effects
  • Implementing file uploads
  • Adding filters and transformations
  • Optimizing performance

Let’s get started!

Step 1: Set Up a Next.js Project

First, create a new Next.js project:

npx create-next-app@latest next-image-editor

Install glfx.js for image processing:

npm install glfx

Initialize ShadCN UI

To set up ShadCN UI for a modern interface, install it with:

npx shadcn-ui@latest init

Then, install the necessary components:

npx shadcn-ui@latest add button select slider card label tabs

Step 2: Add glfx Hook and Types

Create lib/use-glfx.ts to handle loading glfx.js dynamically:

ts
1 "use client";
2
3 import { useEffect, useState } from "react";
4
5 export function useGlfx() {
6 const [status, setStatus] = useState<"loading" | "success" | "error">(
7 "loading"
8 );
9
10 // Add a function to check if WebGL is supported
11 const isWebGLSupported = () => {
12 try {
13 const canvas = document.createElement("canvas");
14 return !!(
15 window.WebGLRenderingContext &&
16 (canvas.getContext("webgl") || canvas.getContext("experimental-webgl"))
17 );
18 } catch (e) {
19 return false;
20 }
21 };
22
23 useEffect(() => {
24 // First check if WebGL is supported
25 if (!isWebGLSupported()) {
26 console.error("WebGL is not supported in this browser");
27 setStatus("error");
28 return;
29 }
30
31 // Check if glfx is already loaded
32 if (window.fx) {
33 setStatus("success");
34 return;
35 }
36
37 // Function to load glfx.js directly
38 const loadGlfx = () => {
39 const script = document.createElement("script");
40 script.src = "https://evanw.github.io/glfx.js/glfx.js";
41 script.async = true;
42
43 script.onload = () => {
44 // Check if window.fx is available after script loads
45 setTimeout(() => {
46 if (window.fx) {
47 setStatus("success");
48 } else {
49 console.error("glfx.js loaded but fx object not available");
50 setStatus("error");
51 }
52 }, 300);
53 };
54
55 script.onerror = () => {
56 console.error("Failed to load glfx.js");
57 setStatus("error");
58 };
59
60 document.body.appendChild(script);
61 return script;
62 };
63
64 // Try to load the script
65 const scriptElement = loadGlfx();
66
67 // Set a timeout for the load attempt
68 const timeout = setTimeout(() => {
69 if (status === "loading") {
70 console.error("glfx.js load timed out");
71 setStatus("error");
72 }
73 }, 5000);
74
75 return () => {
76 clearTimeout(timeout);
77 if (scriptElement && scriptElement.parentNode) {
78 scriptElement.parentNode.removeChild(scriptElement);
79 }
80 };
81 }, [status]);
82
83 return status;
84 }

Create types/glfx.d.ts for type safety:

ts
1 declare global {
2 interface Window {
3 fx: {
4 canvas: () => any;
5 };
6 }
7 }
8
9 export {};

Create html2canvas.d.ts to work with the html2canvas library:

ts
1 declare module "html2canvas" {
2 interface Html2CanvasOptions {
3 allowTaint?: boolean;
4 backgroundColor?: string | null;
5 canvas?: HTMLCanvasElement;
6 foreignObjectRendering?: boolean;
7 imageTimeout?: number;
8 ignoreElements?: (element: Element) => boolean;
9 logging?: boolean;
10 onclone?: (document: Document) => void;
11 proxy?: string;
12 removeContainer?: boolean;
13 scale?: number;
14 useCORS?: boolean;
15 width?: number;
16 height?: number;
17 x?: number;
18 y?: number;
19 scrollX?: number;
20 scrollY?: number;
21 windowWidth?: number;
22 windowHeight?: number;
23 }
24
25 function html2canvas(
26 element: HTMLElement,
27 options?: Html2CanvasOptions
28 ): Promise<HTMLCanvasElement>;
29
30 export default html2canvas;
31 }

Step 3: Create the Image Editor Component

Now, create a component to handle image uploads and apply filters.

Create a new file: components/image-editor.tsx and add the following code:

tsx
1 "use client";
2
3 import { useEffect, useRef, useState } from "react";
4
5 import { Button } from "@/components/ui/button";
6 import { Card } from "@/components/ui/card";
7 import { Label } from "@/components/ui/label";
8 import {
9 Select,
10 SelectContent,
11 SelectItem,
12 SelectTrigger,
13 SelectValue,
14 } from "@/components/ui/select";
15 import { Slider } from "@/components/ui/slider";
16 import { useGlfx } from "@/lib/use-glfx";
17
18 // Simplified filter types
19 type FilterType =
20 | "brightness"
21 | "contrast"
22 | "saturation"
23 | "sepia"
24 | "vignette"
25 | "swirl"
26 | "bulgePinch";
27
28 const filterConfigs = {
29 brightness: {
30 name: "Brightness",
31 params: { amount: { min: -1, max: 1, default: 0, step: 0.01 } },
32 },
33 contrast: {
34 name: "Contrast",
35 params: { amount: { min: -1, max: 1, default: 0, step: 0.01 } },
36 },
37 saturation: {
38 name: "Saturation",
39 params: { amount: { min: -1, max: 1, default: 0, step: 0.01 } },
40 },
41 sepia: {
42 name: "Sepia",
43 params: { amount: { min: 0, max: 1, default: 0.5, step: 0.01 } },
44 },
45 vignette: {
46 name: "Vignette",
47 params: {
48 size: { min: 0, max: 1, default: 0.5, step: 0.01 },
49 amount: { min: 0, max: 1, default: 0.5, step: 0.01 },
50 },
51 },
52 swirl: {
53 name: "Swirl",
54 params: {
55 angle: { min: -25, max: 25, default: 3, step: 0.1 },
56 },
57 },
58 bulgePinch: {
59 name: "Bulge / Pinch",
60 params: {
61 strength: { min: -1, max: 1, default: 0.5, step: 0.01 },
62 },
63 },
64 };
65
66 export default function ImageEditor({ imageUrl }) {
67 const [selectedFilter, setSelectedFilter] =
68 useState<FilterType>("brightness");
69 const [filterParams, setFilterParams] = useState<any>({});
70 const [texture, setTexture] = useState<any>(null);
71 const [canvas, setCanvas] = useState<any>(null);
72 const [originalImage, setOriginalImage] = useState<HTMLImageElement | null>(
73 null
74 );
75
76 const canvasRef = useRef<HTMLCanvasElement>(null);
77 const containerRef = useRef<HTMLDivElement>(null);
78 const editorRef = useRef<HTMLDivElement>(null);
79 const glfxStatus = useGlfx();
80
81 // Initialize filter params when filter changes
82 useEffect(() => {
83 const initialParams = {};
84 Object.entries(filterConfigs[selectedFilter].params).forEach(
85 ([key, param]) => {
86 initialParams[key] = param.default;
87 }
88 );
89 setFilterParams(initialParams);
90 }, [selectedFilter]);
91
92 // Initialize canvas when image loads
93 useEffect(() => {
94 if (!imageUrl || glfxStatus !== "success") return;
95
96 const img = new Image();
97 img.crossOrigin = "anonymous";
98
99 img.onload = () => {
100 setOriginalImage(img);
101
102 if (!window.fx) return;
103
104 try {
105 const glfxCanvas = window.fx.canvas();
106 const glfxTexture = glfxCanvas.texture(img);
107
108 const maxWidth = containerRef.current?.clientWidth || 800;
109 const scale = img.width > maxWidth ? maxWidth / img.width : 1;
110 glfxCanvas.width = Math.floor(img.width * scale);
111 glfxCanvas.height = Math.floor(img.height * scale);
112
113 setTexture(glfxTexture);
114 setCanvas(glfxCanvas);
115
116 if (canvasRef.current?.parentNode) {
117 canvasRef.current.parentNode.replaceChild(
118 glfxCanvas,
119 canvasRef.current
120 );
121 canvasRef.current = glfxCanvas;
122 }
123
124 glfxCanvas.draw(glfxTexture).update();
125 } catch (e) {
126 console.error("Error initializing glfx:", e);
127 }
128 };
129
130 img.src = imageUrl;
131 }, [imageUrl, glfxStatus]);
132
133 // Apply filter when params change
134 useEffect(() => {
135 if (!canvas || !texture) return;
136
137 try {
138 canvas.draw(texture);
139
140 switch (selectedFilter) {
141 case "brightness":
142 canvas.brightnessContrast(filterParams.amount, 0);
143 break;
144 case "contrast":
145 canvas.brightnessContrast(0, filterParams.amount);
146 break;
147 case "saturation":
148 canvas.hueSaturation(0, filterParams.amount);
149 break;
150 case "sepia":
151 canvas.sepia(filterParams.amount);
152 break;
153 case "vignette":
154 canvas.vignette(filterParams.size, filterParams.amount);
155 break;
156 case "swirl":
157 canvas.swirl(
158 canvas.width / 2,
159 canvas.height / 2,
160 canvas.width / 3,
161 filterParams.angle
162 );
163 break;
164 case "bulgePinch":
165 canvas.bulgePinch(
166 canvas.width / 2,
167 canvas.height / 2,
168 canvas.width / 3,
169 filterParams.strength
170 );
171 break;
172 }
173
174 canvas.update();
175 } catch (e) {
176 console.error("Error applying filter:", e);
177 }
178 }, [filterParams, selectedFilter, canvas, texture]);
179
180 // Screenshot-based save method
181 const handleSaveImage = async () => {
182 if (!editorRef.current) return;
183
184 try {
185 // Use html2canvas to capture what's visible on screen
186 const screenshotCanvas = await html2canvas(
187 editorRef.current.querySelector(".canvas-container"),
188 {
189 useCORS: true,
190 backgroundColor: null,
191 scale: 2, // Higher quality
192 }
193 );
194
195 // Create download link
196 const link = document.createElement("a");
197 link.download = "edited-image.png";
198 link.href = screenshotCanvas.toDataURL("image/png");
199 document.body.appendChild(link);
200 link.click();
201 document.body.removeChild(link);
202 } catch (e) {
203 console.error("Error capturing screenshot:", e);
204 alert("Failed to save. Try the Standard Editor instead.");
205 }
206 };
207
208 return (
209 <div className="flex flex-col gap-4 md:flex-row" ref={editorRef}>
210 <div className="flex-1" ref={containerRef}>
211 <div className="canvas-container overflow-hidden rounded-lg bg-black">
212 <canvas ref={canvasRef} className="h-auto max-w-full" />
213 </div>
214 <div className="mt-2 flex justify-between">
215 <div className="text-gray-500 text-xs">WebGL Editor</div>
216 <Button variant="outline" size="sm" onClick={handleSaveImage}>
217 Save Image
218 </Button>
219 </div>
220 </div>
221
222 <Card className="bg-gray-100 w-full md:w-64">
223 <div className="p-4">
224 <div className="mb-4">
225 <Label htmlFor="filter-select">Filter:</Label>
226 <Select
227 value={selectedFilter}
228 onValueChange={(v) => setSelectedFilter(v as FilterType)}
229 >
230 <SelectTrigger id="filter-select">
231 <SelectValue placeholder="Select a filter" />
232 </SelectTrigger>
233 <SelectContent>
234 {Object.entries(filterConfigs).map(([key, config]) => (
235 <SelectItem key={key} value={key}>
236 {config.name}
237 </SelectItem>
238 ))}
239 </SelectContent>
240 </Select>
241 </div>
242
243 <div className="space-y-4">
244 {Object.entries(filterConfigs[selectedFilter].params).map(
245 ([param, config]) => (
246 <div key={param} className="space-y-2">
247 <div className="flex justify-between">
248 <Label htmlFor={`param-${param}`}>{param}:</Label>
249 <span className="text-sm">
250 {filterParams[param]?.toFixed(2)}
251 </span>
252 </div>
253 <Slider
254 id={`param-${param}`}
255 min={config.min}
256 max={config.max}
257 step={config.step}
258 value={[filterParams[param] || config.default]}
259 onValueChange={(value) =>
260 setFilterParams({ ...filterParams, [param]: value[0] })
261 }
262 />
263 </div>
264 )
265 )}
266 </div>
267
268 <Button
269 variant="outline"
270 className="mt-4 w-full"
271 onClick={() => {
272 const initialParams = {};
273 Object.entries(filterConfigs[selectedFilter].params).forEach(
274 ([key, param]) => {
275 initialParams[key] = param.default;
276 }
277 );
278 setFilterParams(initialParams);
279 }}
280 >
281 Reset
282 </Button>
283 </div>
284 </Card>
285 </div>
286 );
287 }

Key Features

  • Uses Next.js and ShadCN UI for UI components (Select, Slider, Card, etc.).
  • Loads glfx.js dynamically using the useGlfx hook.
  • Applies real-time WebGL filters like brightness, contrast, sepia, vignette, etc.
  • Allows users to adjust filter parameters via sliders.
  • Supports saving edited images as PNGs using html2canvas.

Breakdown of Important Parts

1. Filter Configuration The filterConfigs object defines available filters and their parameters (range, default values).

2. State Management

  • selectedFilter stores the active filter.
  • filterParams holds parameter values for the selected filter.
  • texture and canvas handle WebGL rendering.
  • originalImage stores the uploaded image.

3. Image Initialization (useEffect)

When an image is uploaded, it:

  1. Loads into an <img> element.
  2. Creates a WebGL canvas using window.fx.canvas().
  3. Converts the image into a texture.
  4. Scales the image to fit the editor.
  5. Draws the texture to the canvas.

4. Applying Filters (useEffect)

  • When the user selects a filter or adjusts parameters, canvas.draw(texture) applies the filter dynamically.
  • Example: canvas.brightnessContrast(amount, 0); adjusts brightness.

5. Save Feature

Uses html2canvas to take a screenshot of the WebGL editor and save it as an image.

Step 4: Implement a Fallback Editor

Not all browsers support WebGL. If glfx.js fails to load, we can provide a fallback editor using the standard Canvas API.

Create components/fallback-editor.tsx and add:

tsx
1 "use client";
2
3 import { useEffect, useRef, useState } from "react";
4
5 import { Button } from "@/components/ui/button";
6 import { Card } from "@/components/ui/card";
7 import { Label } from "@/components/ui/label";
8 import {
9 Select,
10 SelectContent,
11 SelectItem,
12 SelectTrigger,
13 SelectValue,
14 } from "@/components/ui/select";
15 import { Slider } from "@/components/ui/slider";
16
17 export default function FallbackEditor({ imageUrl }) {
18 const canvasRef = useRef<HTMLCanvasElement>(null);
19 const [brightness, setBrightness] = useState(100);
20 const [contrast, setContrast] = useState(100);
21 const [saturation, setSaturation] = useState(100);
22 const [selectedFilter, setSelectedFilter] = useState("basic");
23 const [originalImage, setOriginalImage] = useState<HTMLImageElement | null>(
24 null
25 );
26
27 // Load image and initialize canvas
28 useEffect(() => {
29 if (!imageUrl || !canvasRef.current) return;
30
31 const canvas = canvasRef.current;
32 const ctx = canvas.getContext("2d");
33 if (!ctx) return;
34
35 const img = new Image();
36 img.crossOrigin = "anonymous";
37
38 img.onload = () => {
39 setOriginalImage(img);
40 canvas.width = img.width;
41 canvas.height = img.height;
42 applyFilters(ctx, img);
43 };
44
45 img.src = imageUrl;
46 }, [imageUrl]);
47
48 // Apply filters when parameters change
49 useEffect(() => {
50 if (!canvasRef.current || !originalImage) return;
51
52 const canvas = canvasRef.current;
53 const ctx = canvas.getContext("2d");
54 if (!ctx) return;
55
56 applyFilters(ctx, originalImage);
57 }, [brightness, contrast, saturation, selectedFilter, originalImage]);
58
59 // Apply filters to canvas
60 const applyFilters = (ctx, img) => {
61 if (!ctx || !img) return;
62
63 // Clear canvas
64 ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
65
66 // Build filter string based on selected filter
67 let filterString = "";
68
69 switch (selectedFilter) {
70 case "basic":
71 filterString = `brightness(${brightness}%) contrast(${contrast}%) saturate(${saturation}%)`;
72 break;
73 case "sepia":
74 filterString = `sepia(${brightness / 100}) contrast(${contrast}%)`;
75 break;
76 case "grayscale":
77 filterString = `grayscale(${brightness / 100}) contrast(${contrast}%)`;
78 break;
79 case "invert":
80 filterString = `invert(${brightness / 100}) contrast(${contrast}%)`;
81 break;
82 default:
83 filterString = `brightness(${brightness}%) contrast(${contrast}%)`;
84 }
85
86 // Apply CSS filters
87 ctx.filter = filterString;
88 ctx.drawImage(img, 0, 0);
89 ctx.filter = "none";
90 };
91
92 // Save the edited image
93 const handleSaveImage = () => {
94 if (!canvasRef.current) return;
95
96 try {
97 const link = document.createElement("a");
98 link.download = "edited-image.png";
99 link.href = canvasRef.current.toDataURL("image/png");
100 document.body.appendChild(link);
101 link.click();
102 document.body.removeChild(link);
103 } catch (e) {
104 console.error("Error saving image:", e);
105 alert("Failed to save the image. Please try again.");
106 }
107 };
108
109 return (
110 <div className="flex flex-col gap-4 md:flex-row">
111 <div className="flex-1">
112 <div className="overflow-hidden rounded-lg bg-black">
113 <canvas ref={canvasRef} className="h-auto max-w-full" />
114 </div>
115 <div className="mt-2 flex justify-between">
116 <div className="text-gray-500 text-xs">Standard Canvas Editor</div>
117 <Button variant="outline" size="sm" onClick={handleSaveImage}>
118 Save Image
119 </Button>
120 </div>
121 </div>
122
123 <Card className="bg-gray-100 w-full md:w-64">
124 <div className="p-4">
125 <div className="mb-4">
126 <Label htmlFor="filter-select">Filter Type:</Label>
127 <Select value={selectedFilter} onValueChange={setSelectedFilter}>
128 <SelectTrigger id="filter-select">
129 <SelectValue placeholder="Select a filter" />
130 </SelectTrigger>
131 <SelectContent>
132 <SelectItem value="basic">Basic Adjustments</SelectItem>
133 <SelectItem value="sepia">Sepia</SelectItem>
134 <SelectItem value="grayscale">Grayscale</SelectItem>
135 <SelectItem value="invert">Invert</SelectItem>
136 </SelectContent>
137 </Select>
138 </div>
139
140 <div className="space-y-4">
141 <div className="space-y-2">
142 <div className="flex justify-between">
143 <Label htmlFor="brightness">
144 {selectedFilter !== "basic"
145 ? "Effect Strength:"
146 : "Brightness:"}
147 </Label>
148 <span className="text-sm">{brightness}%</span>
149 </div>
150 <Slider
151 id="brightness"
152 min={0}
153 max={200}
154 step={1}
155 value={[brightness]}
156 onValueChange={(value) => setBrightness(value[0])}
157 />
158 </div>
159
160 <div className="space-y-2">
161 <div className="flex justify-between">
162 <Label htmlFor="contrast">Contrast:</Label>
163 <span className="text-sm">{contrast}%</span>
164 </div>
165 <Slider
166 id="contrast"
167 min={0}
168 max={200}
169 step={1}
170 value={[contrast]}
171 onValueChange={(value) => setContrast(value[0])}
172 />
173 </div>
174
175 {selectedFilter === "basic" && (
176 <div className="space-y-2">
177 <div className="flex justify-between">
178 <Label htmlFor="saturation">Saturation:</Label>
179 <span className="text-sm">{saturation}%</span>
180 </div>
181 <Slider
182 id="saturation"
183 min={0}
184 max={200}
185 step={1}
186 value={[saturation]}
187 onValueChange={(value) => setSaturation(value[0])}
188 />
189 </div>
190 )}
191 </div>
192
193 <Button
194 variant="outline"
195 className="mt-4 w-full"
196 onClick={() => {
197 setBrightness(100);
198 setContrast(100);
199 setSaturation(100);
200 }}
201 >
202 Reset
203 </Button>
204 </div>
205 </Card>
206 </div>
207 );
208 }

FallbackEditor is a client-side React component that takes an imageUrl prop and lets users apply various filter effects to that image. It uses the native Canvas API rather than a specialized image editing library.

Technical Details

  • Uses React's useRef to access the Canvas DOM element
  • useEffect hooks handle image loading and filter reapplication when parameters change
  • TypeScript is being used (notice the HTMLCanvasElement and HTMLImageElement type annotations)
  • The component is marked with 'use client' directive, indicating it's meant to run on the client side in Next.js

This is a relatively simple implementation of an image editor that provides basic functionality without requiring external image processing libraries.

Step 5: Add a Script Loader

Create an components/script-loader.tsx file and add:

tsx
1 "use client";
2
3 import { useEffect, useState } from "react";
4
5 import { useGlfx } from "@/lib/use-glfx";
6
7 export default function ScriptLoader({ children, fallback }) {
8 const glfxStatus = useGlfx();
9 const [html2canvasLoaded, setHtml2canvasLoaded] = useState(false);
10
11 useEffect(() => {
12 // Check if html2canvas is already loaded
13 if (typeof window !== "undefined" && window.html2canvas) {
14 setHtml2canvasLoaded(true);
15 return;
16 }
17
18 // Create script element to load html2canvas
19 const script = document.createElement("script");
20 script.src = "/html2canvas.min.js";
21 script.async = true;
22 script.onload = () => setHtml2canvasLoaded(true);
23 document.body.appendChild(script);
24
25 return () => {
26 if (script.parentNode) {
27 script.parentNode.removeChild(script);
28 }
29 };
30 }, []);
31
32 if (glfxStatus === "error") {
33 return fallback ? (
34 fallback
35 ) : (
36 <div className="p-4 text-center">
37 <p className="text-red-500">
38 Failed to load WebGL editor. Please try the Standard Editor instead.
39 </p>
40 </div>
41 );
42 }
43
44 if (glfxStatus === "loading" || !html2canvasLoaded) {
45 return (
46 <div className="p-4 text-center">
47 <p>Loading WebGL editor...</p>
48 </div>
49 );
50 }
51
52 return <>{children}</>;
53 }

Your Next.js image editor depends on external JavaScript libraries like:

  • glfx.js (for WebGL image effects)
  • html2canvas (for converting elements into images)

However, these libraries:

✅ Are not built into Next.js and must be loaded dynamically.
✅ Use browser-specific features (like WebGL and the DOM), which won’t work on the server.
✅ May fail to load in certain environments, requiring a fallback solution.

How ScriptLoader Solves These Problems

1. Prevents Crashes in Non-WebGL Browsers

  • If WebGL is unsupported, useGlfx() sets glfxStatus to "error".
  • The fallback UI automatically replaces the WebGL editor with the standard canvas-based editor.

2. Dynamically Loads html2canvas When Needed

  • Instead of bundling html2canvas with your app, it is loaded only when required.
  • This reduces initial page load time and improves performance.

3. Ensures Scripts Are Fully Loaded Before Rendering

  • Prevents "html2canvas is not defined" errors by waiting until the script is ready.
  • Shows a loading message instead of a broken UI while scripts are loading.

What Happens Without ScriptLoader?

❌ The app might crash in browsers without WebGL.
❌ html2canvas could be undefined if used before loading.
❌ Users would see blank screens instead of a graceful fallback.

Step 6: Add a Debug Canvas

For debugging purposes, add a DebugCanvas component to visualize image loading and rendering issues.

Create components/debug-canvas.tsx and add:

tsx
1 "use client";
2
3 import { useEffect, useRef } from "react";
4
5 import { Button } from "@/components/ui/button";
6
7 interface DebugCanvasProps {
8 imageUrl: string;
9 }
10
11 export default function DebugCanvas({ imageUrl }: DebugCanvasProps) {
12 const canvasRef = useRef<HTMLCanvasElement>(null);
13
14 useEffect(() => {
15 if (!imageUrl || !canvasRef.current) return;
16
17 const canvas = canvasRef.current;
18 const ctx = canvas.getContext("2d");
19
20 if (!ctx) {
21 console.error("Could not get 2D context");
22 return;
23 }
24
25 const img = new Image();
26 img.crossOrigin = "anonymous";
27
28 img.onload = () => {
29 // Set canvas dimensions
30 canvas.width = img.width;
31 canvas.height = img.height;
32
33 // Draw the image
34 ctx.drawImage(img, 0, 0);
35
36 console.log("Image loaded in debug canvas", {
37 width: img.width,
38 height: img.height,
39 naturalWidth: img.naturalWidth,
40 naturalHeight: img.naturalHeight,
41 });
42 };
43
44 img.onerror = (e) => {
45 console.error("Error loading image in debug canvas:", e);
46 };
47
48 img.src = imageUrl;
49 }, [imageUrl]);
50
51 const handleSaveImage = () => {
52 if (!canvasRef.current) return;
53
54 try {
55 const link = document.createElement("a");
56 link.download = "debug-image.png";
57 link.href = canvasRef.current.toDataURL("image/png");
58 document.body.appendChild(link);
59 link.click();
60 document.body.removeChild(link);
61 } catch (e) {
62 console.error("Error saving debug image:", e);
63 }
64 };
65
66 return (
67 <div className="border-gray-300 mt-4 rounded-lg border p-4">
68 <h3 className="mb-2 text-lg font-medium">Debug Canvas</h3>
69 <div className="overflow-hidden rounded-lg bg-black">
70 <canvas ref={canvasRef} className="h-auto max-w-full" />
71 </div>
72 <div className="mt-2">
73 <Button variant="outline" size="sm" onClick={handleSaveImage}>
74 Save Debug Image
75 </Button>
76 </div>
77 </div>
78 );
79 }

This DebugCanvas component allows you to display an image on a canvas and provides the option to save the canvas as an image file. Here’s a breakdown of how it works:

1. Component Setup:

  • Props: The component accepts an imageUrl prop, which is the URL of the image that you want to load and display on the canvas.
  • Ref: It uses the useRef hook to create a reference (canvasRef) to the canvas element, which allows access to the canvas DOM node for drawing.

2. Effect Hook:

The useEffect hook is used to load and display the image on the canvas when the imageUrl changes.

3. Image Save Functionality:

  • The handleSaveImage function allows you to save the content of the canvas as a PNG image.
  • It creates a temporary <a> link with a download attribute and sets the href to the data URL of the canvas (which is a base64-encoded PNG of the canvas content).
  • The link is programmatically clicked, triggering the download, and then the link is removed from the document.

This component is useful for debugging image loading and manipulation in the browser. It helps you see the exact content of an image on the canvas and provides a way to save that content.

Step 7: Adding Layout to the Article:

Below is a refined breakdown of your code and how it fits into your article. This layout acts as a global structure around your app, so every page or component rendered as children will inherit the layout style:

tsx
1 import type React from "react";
2
3 import { Inter } from "next/font/google";
4 import Script from "next/script";
5
6 import "./globals.css";
7
8 const inter = Inter({ subsets: ["latin"] });
9
10 export const metadata = {
11 title: "Image Editor with glfx.js",
12 description: "A React-based image editor using glfx.js",
13 };
14
15 export default function RootLayout({
16 children,
17 }: {
18 children: React.ReactNode;
19 }) {
20 return (
21 <html lang="en">
22 <head>
23 <Script
24 src="https://evanw.github.io/glfx.js/glfx.js"
25 strategy="afterInteractive"
26 />
27 </head>
28 <body className={inter.className}>{children}</body>
29 </html>
30 );
31 }

Script Loading:

The Script component is used to include the glfx.js library, which is critical for GPU-accelerated image editing. The strategy="afterInteractive" ensures the script loads only after the page becomes interactive, which improves performance.

This RootLayout would be used as the wrapper for your image editor app, ensuring consistent layout and styling across all pages in your app. The content of your article (or any other page) would be rendered as the children prop, inheriting the layout's properties.

Step 8: Add the Editor to the Page

Now, modify page.tsx to include the image editor. To integrate everything into page.tsx and ensure the image editor works correctly with dynamic components like the ImageEditor, FallbackEditor, and ScriptLoader, here’s a step-by-step explanation of the code:

tsx
1 "use client";
2
3 import { useEffect, useRef, useState } from "react";
4
5 import dynamic from "next/dynamic";
6 import Link from "next/link";
7
8 import FallbackEditor from "@/components/fallback-editor";
9 import { Button } from "@/components/ui/button";
10 import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
11 import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
12
13 // Dynamically import components that use browser APIs
14 const ImageEditor = dynamic(() => import("@/components/image-editor"), {
15 ssr: false,
16 loading: () => <div className="p-4 text-center">Loading WebGL editor...</div>,
17 });
18
19 const ScriptLoader = dynamic(() => import("@/components/script-loader"), {
20 ssr: false,
21 });
22
23 export default function Home() {
24 const [imageUrl, setImageUrl] = useState<string | null>(null);
25 const fileInputRef = useRef<HTMLInputElement>(null);
26
27 // Handle file selection
28 const handleFileChange = (e) => {
29 const file = e.target.files?.[0];
30 if (file) {
31 if (imageUrl) URL.revokeObjectURL(imageUrl);
32 setImageUrl(URL.createObjectURL(file));
33 }
34 };
35
36 // Clean up object URLs when component unmounts
37 useEffect(() => {
38 return () => {
39 if (imageUrl) URL.revokeObjectURL(imageUrl);
40 };
41 }, [imageUrl]);
42
43 return (
44 <main className="bg-gray-900 flex min-h-screen flex-col items-center p-4 text-white md:p-8">
45 <div className="w-full max-w-5xl">
46 <div className="mb-8 text-center">
47 <a href="/">
48 <h1 className="mb-2 text-4xl font-bold">Image Editor</h1>
49 </a>
50 <p className="text-gray-300 text-lg">
51 Edit your images with filters and effects
52 </p>
53 </div>
54
55 <Card className="bg-gray-200 text-gray-900">
56 <CardHeader>
57 <CardTitle className="text-center">Image Editor</CardTitle>
58 </CardHeader>
59 <CardContent>
60 {!imageUrl ? (
61 <div className="border-gray-400 flex flex-col items-center justify-center rounded-lg border-2 border-dashed p-12">
62 <p className="text-gray-600 mb-4">
63 Upload an image to get started
64 </p>
65 <Button onClick={() => fileInputRef.current?.click()}>
66 Select Image
67 </Button>
68 <input
69 type="file"
70 ref={fileInputRef}
71 onChange={handleFileChange}
72 accept="image/*"
73 className="hidden"
74 />
75 </div>
76 ) : (
77 <div className="space-y-4">
78 <Tabs defaultValue="standard" className="w-full">
79 <TabsList className="grid w-full grid-cols-2">
80 <TabsTrigger value="standard">Standard Editor</TabsTrigger>
81 <TabsTrigger value="webgl">WebGL Editor</TabsTrigger>
82 </TabsList>
83 <TabsContent value="standard">
84 <div className="relative">
85 <FallbackEditor imageUrl={imageUrl} />
86 <div className="absolute bottom-2 right-2">
87 <Button
88 variant="secondary"
89 size="sm"
90 onClick={() => setImageUrl(null)}
91 >
92 Change Image
93 </Button>
94 </div>
95 </div>
96 </TabsContent>
97 <TabsContent value="webgl">
98 <ScriptLoader
99 fallback={<FallbackEditor imageUrl={imageUrl} />}
100 >
101 <div className="relative">
102 <ImageEditor imageUrl={imageUrl} />
103 <div className="absolute bottom-2 right-2">
104 <Button
105 variant="secondary"
106 size="sm"
107 onClick={() => setImageUrl(null)}
108 >
109 Change Image
110 </Button>
111 </div>
112 </div>
113 </ScriptLoader>
114 </TabsContent>
115 </Tabs>
116
117 <div className="rounded-lg bg-yellow-100 p-4 text-yellow-800">
118 <p className="text-sm">
119 <strong>Tip:</strong> If you have trouble saving images from
120 the WebGL editor, use the Standard Editor which has better
121 compatibility across browsers.
122 </p>
123 </div>
124 </div>
125 )}
126 </CardContent>
127 </Card>
128 </div>
129 </main>
130 );
131 }

Key Features:

1. File Selection:

The handleFileChange function manages image selection. Once a file is chosen, it creates an object URL and stores it in imageUrl. The URL is revoked after the component unmounts or the image changes, ensuring that no unused URLs are lingering in memory.

2. Dynamic Imports:

  • The ImageEditor and ScriptLoader components are dynamically imported with ssr: false, ensuring they are only loaded on the client-side. This is critical for components that depend on browser-specific APIs (like WebGL).

  • If ImageEditor is not loaded yet, the ScriptLoader will show a fallback editor (in this case, FallbackEditor).

3. Tabs:

  • The app allows switching between two image editors—Standard Editor and WebGL Editor—via tabs. This is implemented with the Tabs component, where each tab displays a different editor.
  • If WebGL isn't supported or fails to load, the fallback editor will be displayed.

4. Image Upload:

If no image is selected (!imageUrl), a file input is shown that allows users to upload an image.

How It All Connects:

1. Page Flow:

  • The page initially asks the user to upload an image.
  • Once the image is uploaded, the user can toggle between two types of editors (standard and WebGL) using tabs.
  • The ScriptLoader dynamically loads the WebGL editor, which is only available after the script (glfx.js) is loaded.
  • If WebGL fails, a fallback editor is shown.

2. User Experience:

The UI ensures a smooth experience where the user can easily upload, edit, and switch between different editors.

This setup efficiently manages dynamic loading, browser-specific behavior, and image manipulation in a React app built with Next.js.

Conclusion

The Image Editor with glfx.js provides a powerful, browser-based solution for image manipulation, combining the simplicity of standard image editing with the advanced capabilities of WebGL. By leveraging the power of glfx.js for GPU-accelerated filters and effects, users can create stunning edits in real-time. With a smooth and user-friendly interface built using Next.js and TailwindCSS, this application ensures a seamless experience for both novice and advanced users.

Whether you're working on basic image enhancements or experimenting with creative WebGL-powered filters, this editor offers flexibility and performance. Additionally, the fallback editor ensures compatibility across a wide range of devices, ensuring your image editing experience remains robust and reliable.

Try it out today, and explore the endless possibilities for image manipulation directly in your browser!

JavaScript Development Space

JSDev Space – Your go-to hub for JavaScript development. Explore expert guides, best practices, and the latest trends in web development, React, Node.js, and more. Stay ahead with cutting-edge tutorials, tools, and insights for modern JS developers. 🚀

Join our growing community of developers! Follow us on social media for updates, coding tips, and exclusive content. Stay connected and level up your JavaScript skills with us! 🔥

© 2025 JavaScript Development Space - Master JS and NodeJS. All rights reserved.