Creating a Next.js Image Editor with glfx.js
1 April 202519 min read
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.
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:
Install glfx.js for image processing:
Initialize ShadCN UI
To set up ShadCN UI for a modern interface, install it with:
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:
1 "use client";23 import { useEffect, useState } from "react";45 export function useGlfx() {6 const [status, setStatus] = useState<"loading" | "success" | "error">(7 "loading"8 );910 // Add a function to check if WebGL is supported11 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 };2223 useEffect(() => {24 // First check if WebGL is supported25 if (!isWebGLSupported()) {26 console.error("WebGL is not supported in this browser");27 setStatus("error");28 return;29 }3031 // Check if glfx is already loaded32 if (window.fx) {33 setStatus("success");34 return;35 }3637 // Function to load glfx.js directly38 const loadGlfx = () => {39 const script = document.createElement("script");40 script.src = "https://evanw.github.io/glfx.js/glfx.js";41 script.async = true;4243 script.onload = () => {44 // Check if window.fx is available after script loads45 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 };5455 script.onerror = () => {56 console.error("Failed to load glfx.js");57 setStatus("error");58 };5960 document.body.appendChild(script);61 return script;62 };6364 // Try to load the script65 const scriptElement = loadGlfx();6667 // Set a timeout for the load attempt68 const timeout = setTimeout(() => {69 if (status === "loading") {70 console.error("glfx.js load timed out");71 setStatus("error");72 }73 }, 5000);7475 return () => {76 clearTimeout(timeout);77 if (scriptElement && scriptElement.parentNode) {78 scriptElement.parentNode.removeChild(scriptElement);79 }80 };81 }, [status]);8283 return status;84 }
Create types/glfx.d.ts
for type safety:
1 declare global {2 interface Window {3 fx: {4 canvas: () => any;5 };6 }7 }89 export {};
Create html2canvas.d.ts
to work with the html2canvas
library:
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 }2425 function html2canvas(26 element: HTMLElement,27 options?: Html2CanvasOptions28 ): Promise<HTMLCanvasElement>;2930 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:
1 "use client";23 import { useEffect, useRef, useState } from "react";45 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";1718 // Simplified filter types19 type FilterType =20 | "brightness"21 | "contrast"22 | "saturation"23 | "sepia"24 | "vignette"25 | "swirl"26 | "bulgePinch";2728 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 };6566 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 null74 );7576 const canvasRef = useRef<HTMLCanvasElement>(null);77 const containerRef = useRef<HTMLDivElement>(null);78 const editorRef = useRef<HTMLDivElement>(null);79 const glfxStatus = useGlfx();8081 // Initialize filter params when filter changes82 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]);9192 // Initialize canvas when image loads93 useEffect(() => {94 if (!imageUrl || glfxStatus !== "success") return;9596 const img = new Image();97 img.crossOrigin = "anonymous";9899 img.onload = () => {100 setOriginalImage(img);101102 if (!window.fx) return;103104 try {105 const glfxCanvas = window.fx.canvas();106 const glfxTexture = glfxCanvas.texture(img);107108 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);112113 setTexture(glfxTexture);114 setCanvas(glfxCanvas);115116 if (canvasRef.current?.parentNode) {117 canvasRef.current.parentNode.replaceChild(118 glfxCanvas,119 canvasRef.current120 );121 canvasRef.current = glfxCanvas;122 }123124 glfxCanvas.draw(glfxTexture).update();125 } catch (e) {126 console.error("Error initializing glfx:", e);127 }128 };129130 img.src = imageUrl;131 }, [imageUrl, glfxStatus]);132133 // Apply filter when params change134 useEffect(() => {135 if (!canvas || !texture) return;136137 try {138 canvas.draw(texture);139140 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.angle162 );163 break;164 case "bulgePinch":165 canvas.bulgePinch(166 canvas.width / 2,167 canvas.height / 2,168 canvas.width / 3,169 filterParams.strength170 );171 break;172 }173174 canvas.update();175 } catch (e) {176 console.error("Error applying filter:", e);177 }178 }, [filterParams, selectedFilter, canvas, texture]);179180 // Screenshot-based save method181 const handleSaveImage = async () => {182 if (!editorRef.current) return;183184 try {185 // Use html2canvas to capture what's visible on screen186 const screenshotCanvas = await html2canvas(187 editorRef.current.querySelector(".canvas-container"),188 {189 useCORS: true,190 backgroundColor: null,191 scale: 2, // Higher quality192 }193 );194195 // Create download link196 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 };207208 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 Image218 </Button>219 </div>220 </div>221222 <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 <Select227 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>242243 <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 <Slider254 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>267268 <Button269 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 Reset282 </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
andcanvas
handle WebGL rendering.originalImage
stores the uploaded image.
3. Image Initialization (useEffect
)
When an image is uploaded, it:
- Loads into an
<img>
element. - Creates a WebGL canvas using
window.fx.canvas()
. - Converts the image into a
texture
. - Scales the image to fit the editor.
- 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:
1 "use client";23 import { useEffect, useRef, useState } from "react";45 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";1617 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 null25 );2627 // Load image and initialize canvas28 useEffect(() => {29 if (!imageUrl || !canvasRef.current) return;3031 const canvas = canvasRef.current;32 const ctx = canvas.getContext("2d");33 if (!ctx) return;3435 const img = new Image();36 img.crossOrigin = "anonymous";3738 img.onload = () => {39 setOriginalImage(img);40 canvas.width = img.width;41 canvas.height = img.height;42 applyFilters(ctx, img);43 };4445 img.src = imageUrl;46 }, [imageUrl]);4748 // Apply filters when parameters change49 useEffect(() => {50 if (!canvasRef.current || !originalImage) return;5152 const canvas = canvasRef.current;53 const ctx = canvas.getContext("2d");54 if (!ctx) return;5556 applyFilters(ctx, originalImage);57 }, [brightness, contrast, saturation, selectedFilter, originalImage]);5859 // Apply filters to canvas60 const applyFilters = (ctx, img) => {61 if (!ctx || !img) return;6263 // Clear canvas64 ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);6566 // Build filter string based on selected filter67 let filterString = "";6869 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 }8586 // Apply CSS filters87 ctx.filter = filterString;88 ctx.drawImage(img, 0, 0);89 ctx.filter = "none";90 };9192 // Save the edited image93 const handleSaveImage = () => {94 if (!canvasRef.current) return;9596 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 };108109 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 Image119 </Button>120 </div>121 </div>122123 <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>139140 <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 <Slider151 id="brightness"152 min={0}153 max={200}154 step={1}155 value={[brightness]}156 onValueChange={(value) => setBrightness(value[0])}157 />158 </div>159160 <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 <Slider166 id="contrast"167 min={0}168 max={200}169 step={1}170 value={[contrast]}171 onValueChange={(value) => setContrast(value[0])}172 />173 </div>174175 {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 <Slider182 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>192193 <Button194 variant="outline"195 className="mt-4 w-full"196 onClick={() => {197 setBrightness(100);198 setContrast(100);199 setSaturation(100);200 }}201 >202 Reset203 </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
andHTMLImageElement
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:
1 "use client";23 import { useEffect, useState } from "react";45 import { useGlfx } from "@/lib/use-glfx";67 export default function ScriptLoader({ children, fallback }) {8 const glfxStatus = useGlfx();9 const [html2canvasLoaded, setHtml2canvasLoaded] = useState(false);1011 useEffect(() => {12 // Check if html2canvas is already loaded13 if (typeof window !== "undefined" && window.html2canvas) {14 setHtml2canvasLoaded(true);15 return;16 }1718 // Create script element to load html2canvas19 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);2425 return () => {26 if (script.parentNode) {27 script.parentNode.removeChild(script);28 }29 };30 }, []);3132 if (glfxStatus === "error") {33 return fallback ? (34 fallback35 ) : (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 }4344 if (glfxStatus === "loading" || !html2canvasLoaded) {45 return (46 <div className="p-4 text-center">47 <p>Loading WebGL editor...</p>48 </div>49 );50 }5152 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()
setsglfxStatus
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:
1 "use client";23 import { useEffect, useRef } from "react";45 import { Button } from "@/components/ui/button";67 interface DebugCanvasProps {8 imageUrl: string;9 }1011 export default function DebugCanvas({ imageUrl }: DebugCanvasProps) {12 const canvasRef = useRef<HTMLCanvasElement>(null);1314 useEffect(() => {15 if (!imageUrl || !canvasRef.current) return;1617 const canvas = canvasRef.current;18 const ctx = canvas.getContext("2d");1920 if (!ctx) {21 console.error("Could not get 2D context");22 return;23 }2425 const img = new Image();26 img.crossOrigin = "anonymous";2728 img.onload = () => {29 // Set canvas dimensions30 canvas.width = img.width;31 canvas.height = img.height;3233 // Draw the image34 ctx.drawImage(img, 0, 0);3536 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 };4344 img.onerror = (e) => {45 console.error("Error loading image in debug canvas:", e);46 };4748 img.src = imageUrl;49 }, [imageUrl]);5051 const handleSaveImage = () => {52 if (!canvasRef.current) return;5354 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 };6566 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 Image75 </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 animageUrl
prop, which is the URL of the image that you want to load and display on the canvas.Ref
: It uses theuseRef
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 adownload
attribute and sets thehref
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:
1 import type React from "react";23 import { Inter } from "next/font/google";4 import Script from "next/script";56 import "./globals.css";78 const inter = Inter({ subsets: ["latin"] });910 export const metadata = {11 title: "Image Editor with glfx.js",12 description: "A React-based image editor using glfx.js",13 };1415 export default function RootLayout({16 children,17 }: {18 children: React.ReactNode;19 }) {20 return (21 <html lang="en">22 <head>23 <Script24 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:
1 "use client";23 import { useEffect, useRef, useState } from "react";45 import dynamic from "next/dynamic";6 import Link from "next/link";78 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";1213 // Dynamically import components that use browser APIs14 const ImageEditor = dynamic(() => import("@/components/image-editor"), {15 ssr: false,16 loading: () => <div className="p-4 text-center">Loading WebGL editor...</div>,17 });1819 const ScriptLoader = dynamic(() => import("@/components/script-loader"), {20 ssr: false,21 });2223 export default function Home() {24 const [imageUrl, setImageUrl] = useState<string | null>(null);25 const fileInputRef = useRef<HTMLInputElement>(null);2627 // Handle file selection28 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 };3536 // Clean up object URLs when component unmounts37 useEffect(() => {38 return () => {39 if (imageUrl) URL.revokeObjectURL(imageUrl);40 };41 }, [imageUrl]);4243 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 effects52 </p>53 </div>5455 <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 started64 </p>65 <Button onClick={() => fileInputRef.current?.click()}>66 Select Image67 </Button>68 <input69 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 <Button88 variant="secondary"89 size="sm"90 onClick={() => setImageUrl(null)}91 >92 Change Image93 </Button>94 </div>95 </div>96 </TabsContent>97 <TabsContent value="webgl">98 <ScriptLoader99 fallback={<FallbackEditor imageUrl={imageUrl} />}100 >101 <div className="relative">102 <ImageEditor imageUrl={imageUrl} />103 <div className="absolute bottom-2 right-2">104 <Button105 variant="secondary"106 size="sm"107 onClick={() => setImageUrl(null)}108 >109 Change Image110 </Button>111 </div>112 </div>113 </ScriptLoader>114 </TabsContent>115 </Tabs>116117 <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 from120 the WebGL editor, use the Standard Editor which has better121 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
andScriptLoader
components are dynamically imported withssr: 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, theScriptLoader
will show a fallback editor (in this case,FallbackEditor
).
3. Tabs:
- The app allows switching between two image editors—
Standard Editor
andWebGL Editor
—via tabs. This is implemented with theTabs
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!