Engraving Tool
Preact-based custom engraving interface with 15 fonts, real-time canvas preview, and GSM Outdoors API integration
Overview
The SOG Knives custom engraving tool allows customers to personalize their knife purchases with custom text engraving. Built with Preact for lightweight performance, the tool provides real-time canvas preview, supports 15 custom fonts, and integrates with the GSM Outdoors API for order processing.
Framework: Preact (React alternative, 3KB)
Rendering: HTML5 Canvas API
Fonts: 15 custom fonts including Stratum2, Blade Runner, Terminator
API: GSM Outdoors Engraving API
Validation: Client-side + server-side
Architecture
Component Structure
Engraving Tool (Preact App)
├── <EngravingApp /> # Root component
│ ├── <EngravingForm /> # Main form container
│ │ ├── <FontSelector /> # Font dropdown (15 options)
│ │ ├── <TextInput /> # Text entry with validation
│ │ ├── <PositionControl /> # Alignment options
│ │ ├── <LineSpacingSlider /> # Line spacing adjustment
│ │ └── <SubmitButton /> # Form submission
│ ├── <CanvasPreview /> # Real-time rendering
│ ├── <ErrorDisplay /> # Validation errors
│ └── <LoadingSpinner /> # API call indicator
└── <EngravingAPI /> # API client service
File Structure
assets/js/engraving/
├── app.jsx # Entry point, Preact app initialization
├── components/
│ ├── EngravingApp.jsx # Root component
│ ├── EngravingForm.jsx # Form container
│ ├── FontSelector.jsx # Font dropdown
│ ├── TextInput.jsx # Text input with validation
│ ├── PositionControl.jsx # Text alignment
│ ├── LineSpacingSlider.jsx # Spacing control
│ ├── CanvasPreview.jsx # Canvas rendering
│ ├── ErrorDisplay.jsx # Error messages
│ └── LoadingSpinner.jsx # Loading indicator
├── services/
│ ├── EngravingAPI.js # GSM API client
│ └── ValidationService.js # Text validation
├── utils/
│ ├── canvasRenderer.js # Canvas drawing logic
│ ├── fontLoader.js # Font loading/caching
│ └── textMeasurement.js # Text dimension calculations
└── config/
├── fonts.js # Font definitions
└── constants.js # Configuration constants
Implementation
Initialization
The engraving tool initializes when the product page detects an engravable product:
// assets/js/theme/product.js
import { render } from 'preact';
import EngravingApp from './engraving/components/EngravingApp';
export function initEngravingTool(container) {
const productId = container.dataset.productId;
const maxChars = parseInt(container.dataset.maxChars, 10) || 20;
const allowedFonts = container.dataset.allowedFonts?.split(',') || null;
render(
<EngravingApp
productId={productId}
maxCharacters={maxChars}
allowedFonts={allowedFonts}
/>,
document.getElementById('engraving-app')
);
}
Root Component
// assets/js/engraving/components/EngravingApp.jsx
import { h } from 'preact';
import { useState, useEffect } from 'preact/hooks';
import EngravingForm from './EngravingForm';
import CanvasPreview from './CanvasPreview';
import ErrorDisplay from './ErrorDisplay';
import LoadingSpinner from './LoadingSpinner';
import { EngravingAPI } from '../services/EngravingAPI';
export default function EngravingApp({ productId, maxCharacters, allowedFonts }) {
const [engravingText, setEngravingText] = useState('');
const [selectedFont, setSelectedFont] = useState('stratum2-regular');
const [textPosition, setTextPosition] = useState('center');
const [lineSpacing, setLineSpacing] = useState(1.2);
const [errors, setErrors] = useState([]);
const [isSubmitting, setIsSubmitting] = useState(false);
const [engravingId, setEngravingId] = useState(null);
const handleSubmit = async () => {
setIsSubmitting(true);
setErrors([]);
try {
const response = await EngravingAPI.submitEngraving({
product_id: productId,
engraving_text: engravingText,
font: selectedFont,
position: textPosition,
line_spacing: lineSpacing
});
setEngravingId(response.engraving_id);
// Attach engraving ID to cart form
document.getElementById('engraving-id-field').value = response.engraving_id;
// Show success message
showSuccessNotification('Engraving preview created!');
} catch (error) {
setErrors([error.message]);
} finally {
setIsSubmitting(false);
}
};
return (
<div class="engraving-tool">
<h3 class="engraving-tool__title">Personalize Your Knife</h3>
<div class="engraving-tool__container">
<div class="engraving-tool__form">
<EngravingForm
text={engravingText}
onTextChange={setEngravingText}
font={selectedFont}
onFontChange={setSelectedFont}
position={textPosition}
onPositionChange={setTextPosition}
lineSpacing={lineSpacing}
onLineSpacingChange={setLineSpacing}
maxCharacters={maxCharacters}
allowedFonts={allowedFonts}
onSubmit={handleSubmit}
isSubmitting={isSubmitting}
/>
</div>
<div class="engraving-tool__preview">
<CanvasPreview
text={engravingText}
font={selectedFont}
position={textPosition}
lineSpacing={lineSpacing}
/>
</div>
</div>
{errors.length > 0 && <ErrorDisplay errors={errors} />}
{isSubmitting && <LoadingSpinner />}
</div>
);
}
Font Configuration
// assets/js/engraving/config/fonts.js
export const ENGRAVING_FONTS = [
{
id: 'stratum2-regular',
name: 'Stratum2 Regular',
family: 'Stratum2',
weight: 400,
style: 'normal',
file: '/assets/fonts/Stratum2-Regular.woff2'
},
{
id: 'stratum2-bold',
name: 'Stratum2 Bold',
family: 'Stratum2',
weight: 700,
style: 'normal',
file: '/assets/fonts/Stratum2-Bold.woff2'
},
{
id: 'blade-runner',
name: 'Blade Runner',
family: 'BladeRunner',
weight: 400,
style: 'normal',
file: '/assets/fonts/BladeRunner.woff2'
},
{
id: 'terminator',
name: 'Terminator',
family: 'Terminator',
weight: 400,
style: 'normal',
file: '/assets/fonts/Terminator.woff2'
},
{
id: 'arial',
name: 'Arial',
family: 'Arial',
weight: 400,
style: 'normal',
system: true
},
{
id: 'times-new-roman',
name: 'Times New Roman',
family: 'Times New Roman',
weight: 400,
style: 'normal',
system: true
},
{
id: 'courier',
name: 'Courier',
family: 'Courier New',
weight: 400,
style: 'normal',
system: true
},
{
id: 'brush-script',
name: 'Brush Script',
family: 'Brush Script MT',
weight: 400,
style: 'italic',
file: '/assets/fonts/BrushScript.woff2'
},
{
id: 'old-english',
name: 'Old English',
family: 'Old English Text MT',
weight: 400,
style: 'normal',
file: '/assets/fonts/OldEnglish.woff2'
},
{
id: 'stencil',
name: 'Stencil',
family: 'Stencil Std',
weight: 700,
style: 'normal',
file: '/assets/fonts/Stencil.woff2'
},
{
id: 'impact',
name: 'Impact',
family: 'Impact',
weight: 400,
style: 'normal',
system: true
},
{
id: 'comic-sans',
name: 'Comic Sans',
family: 'Comic Sans MS',
weight: 400,
style: 'normal',
system: true
},
{
id: 'helvetica-neue',
name: 'Helvetica Neue',
family: 'Helvetica Neue',
weight: 400,
style: 'normal',
system: true
},
{
id: 'georgia',
name: 'Georgia',
family: 'Georgia',
weight: 400,
style: 'normal',
system: true
},
{
id: 'verdana',
name: 'Verdana',
family: 'Verdana',
weight: 400,
style: 'normal',
system: true
}
];
// Filter fonts based on product configuration
export function getAvailableFonts(allowedFonts) {
if (!allowedFonts || allowedFonts.length === 0) {
return ENGRAVING_FONTS;
}
return ENGRAVING_FONTS.filter(font => allowedFonts.includes(font.id));
}
Canvas Rendering
// assets/js/engraving/utils/canvasRenderer.js
export class EngravingRenderer {
constructor(canvas, options = {}) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
this.options = {
width: options.width || 400,
height: options.height || 200,
backgroundColor: options.backgroundColor || '#f5f5f5',
textColor: options.textColor || '#000000',
padding: options.padding || 20
};
this.initCanvas();
}
initCanvas() {
this.canvas.width = this.options.width;
this.canvas.height = this.options.height;
}
render(text, font, position, lineSpacing) {
// Clear canvas
this.ctx.fillStyle = this.options.backgroundColor;
this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
if (!text) return;
// Split text into lines
const lines = text.split('\n');
// Set font
this.ctx.font = `${font.weight} 24px ${font.family}`;
this.ctx.fillStyle = this.options.textColor;
this.ctx.textBaseline = 'middle';
// Calculate line height
const lineHeight = 24 * lineSpacing;
const totalHeight = lines.length * lineHeight;
let startY = (this.canvas.height - totalHeight) / 2;
// Render each line
lines.forEach((line, index) => {
const y = startY + (index * lineHeight);
this.renderLine(line, y, position);
});
}
renderLine(text, y, position) {
const metrics = this.ctx.measureText(text);
let x;
switch (position) {
case 'left':
x = this.options.padding;
this.ctx.textAlign = 'left';
break;
case 'right':
x = this.canvas.width - this.options.padding;
this.ctx.textAlign = 'right';
break;
case 'center':
default:
x = this.canvas.width / 2;
this.ctx.textAlign = 'center';
break;
}
this.ctx.fillText(text, x, y);
}
exportImage(format = 'png', quality = 0.92) {
return this.canvas.toDataURL(`image/${format}`, quality);
}
}
GSM Outdoors API Integration
API Service
// assets/js/engraving/services/EngravingAPI.js
export class EngravingAPI {
static BASE_URL = 'https://api.gsmoutdoors.com/v1';
static API_KEY = window.gsmApiKey; // Injected by theme
static async submitEngraving(data) {
const response = await fetch(`${this.BASE_URL}/api/engraving/orders`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.API_KEY}`
},
body: JSON.stringify(data)
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || 'Failed to submit engraving');
}
return response.json();
}
static async validateText(text, productId) {
const response = await fetch(`${this.BASE_URL}/api/engraving/validate`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.API_KEY}`
},
body: JSON.stringify({ text, product_id: productId })
});
return response.json();
}
static async getEngravingPreview(engravingId) {
const response = await fetch(
`${this.BASE_URL}/api/engraving/orders/${engravingId}/preview`,
{
headers: {
'Authorization': `Bearer ${this.API_KEY}`
}
}
);
return response.json();
}
}
API Endpoints
Submit Engraving Order
POST /api/engraving/orders
Content-Type: application/json
Authorization: Bearer {api_key}
{
"product_id": "12345",
"engraving_text": "John Smith\nUSMC",
"font": "stratum2-bold",
"position": "center",
"line_spacing": 1.2
}
Response:
{
"engraving_id": "eng_abc123xyz",
"preview_url": "https://cdn.gsmoutdoors.com/previews/eng_abc123xyz.png",
"status": "pending",
"estimated_completion": "2026-04-25T10:00:00Z"
}
Validate Engraving Text
POST /api/engraving/validate
Content-Type: application/json
Authorization: Bearer {api_key}
{
"text": "John Smith",
"product_id": "12345"
}
Response:
{
"valid": true,
"errors": [],
"warnings": [
"Text may be difficult to read at small sizes"
]
}
Validation Rules
Client-Side Validation
// assets/js/engraving/services/ValidationService.js
export class ValidationService {
static validate(text, maxCharacters) {
const errors = [];
// Check if empty
if (!text || text.trim().length === 0) {
errors.push('Engraving text cannot be empty');
return { valid: false, errors };
}
// Check character limit
if (text.length > maxCharacters) {
errors.push(`Text exceeds maximum length of ${maxCharacters} characters`);
}
// Check for invalid characters
const invalidChars = /[<>{}[\]\\|]/g;
if (invalidChars.test(text)) {
errors.push('Text contains invalid characters: < > { } [ ] \\ |');
}
// Check line length (max 2 lines)
const lines = text.split('\n');
if (lines.length > 2) {
errors.push('Maximum of 2 lines allowed');
}
// Check individual line length
lines.forEach((line, index) => {
if (line.length > 15) {
errors.push(`Line ${index + 1} exceeds 15 characters`);
}
});
return {
valid: errors.length === 0,
errors
};
}
static sanitizeText(text) {
return text
.replace(/[<>{}[\]\\|]/g, '') // Remove invalid chars
.substring(0, 30) // Hard limit
.trim();
}
}
Server-Side Validation
The GSM Outdoors API performs additional validation:
- Profanity filter: Checks against profanity database
- SQL injection: Parameterized queries, input sanitization
- XSS prevention: HTML entity encoding
- Rate limiting: 10 submissions per minute per IP
Error Handling
Error Display Component
// assets/js/engraving/components/ErrorDisplay.jsx
import { h } from 'preact';
export default function ErrorDisplay({ errors }) {
if (!errors || errors.length === 0) return null;
return (
<div class="engraving-errors" role="alert" aria-live="polite">
<h4 class="engraving-errors__title">Please fix the following:</h4>
<ul class="engraving-errors__list">
{errors.map((error, index) => (
<li key={index} class="engraving-errors__item">
{error}
</li>
))}
</ul>
</div>
);
}
Error Styling
// assets/scss/components/_engraving-tool.scss
.engraving-errors {
margin: 16px 0;
padding: 16px;
background: #fef2f2;
border: 2px solid #ef4444;
border-radius: 8px;
&__title {
margin: 0 0 8px;
color: #dc2626;
font-size: 1rem;
font-weight: 700;
}
&__list {
margin: 0;
padding-left: 20px;
color: #991b1b;
}
&__item {
margin: 4px 0;
}
}
Cart Integration
Adding Engraving to Cart
When the customer adds a product to cart, the engraving ID is included:
// assets/js/theme/product.js
async function addToCart(formData, productId, engravingId) {
const cartItem = {
product_id: productId,
quantity: formData.get('qty'),
option_selections: [],
customizations: {
engraving_id: engravingId
}
};
const response = await fetch('/api/storefront/carts/items', {
method: 'POST',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(cartItem)
});
return response.json();
}
Cart Display
Engraving preview shown in cart:
Performance Optimization
Font Loading
Fonts are preloaded and cached:
// assets/js/engraving/utils/fontLoader.js
export class FontLoader {
static cache = new Map();
static async loadFont(font) {
if (this.cache.has(font.id)) {
return this.cache.get(font.id);
}
if (font.system) {
// System fonts are already available
this.cache.set(font.id, true);
return true;
}
// Load custom font
const fontFace = new FontFace(font.family, `url(${font.file})`, {
weight: font.weight,
style: font.style
});
try {
const loadedFont = await fontFace.load();
document.fonts.add(loadedFont);
this.cache.set(font.id, true);
return true;
} catch (error) {
console.error(`Failed to load font: ${font.name}`, error);
return false;
}
}
static async preloadAllFonts(fonts) {
const promises = fonts.map(font => this.loadFont(font));
await Promise.all(promises);
}
}
Canvas Debouncing
Render updates are debounced to improve performance:
import { debounce } from '../utils/debounce';
const debouncedRender = debounce((renderer, text, font, position, spacing) => {
renderer.render(text, font, position, spacing);
}, 150);
Testing
Unit Tests
// assets/js/engraving/__tests__/ValidationService.test.js
import { ValidationService } from '../services/ValidationService';
describe('ValidationService', () => {
test('validates empty text', () => {
const result = ValidationService.validate('', 20);
expect(result.valid).toBe(false);
expect(result.errors).toContain('Engraving text cannot be empty');
});
test('validates character limit', () => {
const longText = 'A'.repeat(25);
const result = ValidationService.validate(longText, 20);
expect(result.valid).toBe(false);
expect(result.errors.length).toBeGreaterThan(0);
});
test('validates invalid characters', () => {
const result = ValidationService.validate('Hello<script>', 20);
expect(result.valid).toBe(false);
});
test('validates line count', () => {
const threeLines = 'Line 1\nLine 2\nLine 3';
const result = ValidationService.validate(threeLines, 50);
expect(result.valid).toBe(false);
expect(result.errors).toContain('Maximum of 2 lines allowed');
});
});