import { useState, useEffect, useRef } from 'react';
import Layout from '../components/Layout';
import LoadingButton from '../components/LoadingButton';
import SortTh from '../components/SortTh';
import { useSortable } from '../hooks/useSortable';
import { supabase } from '../lib/supabase';
import { generateBrandBookEditorPDF } from '../lib/brandBookEditor';
import { cleanupBrandBookStorage } from '../lib/deleteHelpers';
import { popupOverlayStyle, popupSurfaceStyle } from '../lib/popupStyles';
const BUCKET = 'brand-books';
const EMPTY_SIGN = () => ({
_key: Math.random().toString(36).slice(2),
signNumber: '',
type: '',
recommendation: '',
specifications: '',
notes: '',
photo: null,
photoPath: '',
_photoPreview: '',
existingPhoto: null,
existingPhotoPath: '',
_existingPhotoPreview: '',
recommendationPhoto: null,
recommendationPhotoPath: '',
_recommendationPhotoPreview: '',
signDetailPhoto: null,
signDetailPhotoPath: '',
_signDetailPhotoPreview: '',
});
const EMPTY_BOOK_INFO = {
clientId: '',
clientName: '',
projectName: '',
siteAddress: '',
bookDate: new Date().toISOString().split('T')[0],
preparedBy: '',
revision: '01',
template: 'fourge',
// Cover page
creationDate: new Date().toISOString().slice(0, 10),
revisionDate: '',
customerName: '',
customerAddress: '',
clientLogoUrl: '',
clientContactName: '',
clientContactEmail: '',
clientContactPhone: '',
approvedDate: '',
approvalNotes: '',
};
const TEMPLATE_OPTIONS = [
{ value: 'fourge', label: 'Fourge Branding Template' },
{ value: 'bolchoz', label: 'Bolchoz Sign Solutions' },
];
const PHOTO_FILE_ACCEPT = 'image/*,.heic,.heif,.avif,.tif,.tiff,.bmp,.webp,.jpeg,.jpg,.png,.gif';
const PHOTO_FILE_EXTENSIONS = new Set(['heic', 'heif', 'avif', 'tif', 'tiff', 'bmp', 'webp', 'jpeg', 'jpg', 'png', 'gif']);
const MAPBOX_TOKEN = import.meta.env.VITE_MAPBOX_TOKEN || '';
const normalizeRevision = (value) => {
const digits = String(value ?? '').replace(/\D/g, '').slice(0, 2);
if (!digits) return '00';
return digits.padStart(2, '0');
};
const isPhotoFile = (file) => {
if (!file) return false;
if (file.type?.startsWith('image/')) return true;
const extension = file.name?.split('.').pop()?.toLowerCase();
return extension ? PHOTO_FILE_EXTENSIONS.has(extension) : false;
};
const getMapboxCoordinates = async (address) => {
const features = await getMapboxAddressSuggestions(address, 1);
const coordinates = features[0]?.center;
if (!coordinates || coordinates.length < 2) throw new Error('No map result found for that address.');
return coordinates;
};
const getMapboxAddressSuggestions = async (address, limit = 5) => {
const params = new URLSearchParams({
access_token: MAPBOX_TOKEN,
autocomplete: 'true',
country: 'us',
limit: String(limit),
types: 'address,place,postcode,locality,neighborhood,poi',
});
const response = await fetch(`https://api.mapbox.com/geocoding/v5/mapbox.places/${encodeURIComponent(address)}.json?${params.toString()}`);
if (!response.ok) throw new Error('Unable to find that address.');
const data = await response.json();
return data.features || [];
};
const buildStaticMapUrl = ([lng, lat], style = 'satellite-streets-v12', zoom = 18) => {
if (!MAPBOX_TOKEN || lng == null || lat == null) return '';
const marker = `pin-s+f5a523(${lng},${lat})`;
const camera = `${lng},${lat},${zoom},0`;
return `https://api.mapbox.com/styles/v1/mapbox/${style}/static/${marker}/${camera}/700x467@2x?access_token=${encodeURIComponent(MAPBOX_TOKEN)}`;
};
// ─── Helpers ──────────────────────────────────────────────────────────────────
const getSignedUrl = async (path) => {
if (!path) return null;
const { data } = await supabase.storage.from(BUCKET).createSignedUrl(path, 86400 * 7);
return data?.signedUrl || null;
};
const uploadFile = async (file, path) => {
const { error } = await supabase.storage.from(BUCKET).upload(path, file, { upsert: true });
if (error) throw error;
return path;
};
// ─── Main Component ───────────────────────────────────────────────────────────
export default function BrandBook() {
const [view, setView] = useState('list');
const [savedBooks, setSavedBooks] = useState([]);
const [loadingBooks, setLoadingBooks] = useState(true);
const [currentId, setCurrentId] = useState(null);
const [clients, setClients] = useState([]);
const [bookInfo, setBookInfo] = useState(EMPTY_BOOK_INFO);
const [siteMapFile, setSiteMapFile] = useState(null);
const [siteMapPath, setSiteMapPath] = useState('');
const [siteMapPreview, setSiteMapPreview] = useState(null);
const [autoMapAddress, setAutoMapAddress] = useState('');
const [autoMapLoading, setAutoMapLoading] = useState(false);
const [autoMapError, setAutoMapError] = useState('');
const [siteMapManuallyCleared, setSiteMapManuallyCleared] = useState(false);
const [inventoryMapManuallyCleared, setInventoryMapManuallyCleared] = useState(false);
const [addressSuggestions, setAddressSuggestions] = useState([]);
const [addressSuggesting, setAddressSuggesting] = useState(false);
const [showAddressSuggestions, setShowAddressSuggestions] = useState(false);
const [signs, setSigns] = useState([EMPTY_SIGN()]);
const [photoItems, setPhotoItems] = useState([]);
const [inventoryMapFile, setInventoryMapFile] = useState(null);
const [inventoryMapPath, setInventoryMapPath] = useState('');
const [inventoryMapPreview, setInventoryMapPreview] = useState(null);
const [generating, setGenerating] = useState(false);
const [saving, setSaving] = useState(false);
const [notification, setNotification] = useState(null);
const [projectLogoFile, setProjectLogoFile] = useState(null);
const [projectLogoPath, setProjectLogoPath] = useState('');
const [projectLogoPreview, setProjectLogoPreview] = useState('');
const [uploadingClientLogo, setUploadingClientLogo] = useState(false);
const [savingClientInfo, setSavingClientInfo] = useState(false);
const [clientInfoSaved, setClientInfoSaved] = useState(false);
const siteMapRef = useRef();
const inventoryMapRef = useRef();
const sitePhotosRef = useRef();
const projectLogoRef = useRef();
const clientLogoRef = useRef();
const [filterCompany, setFilterCompany] = useState('');
const { sortKey: bbSortKey, sortDir: bbSortDir, toggle: bbToggle, sort: bbSort } = useSortable('updated_at');
useEffect(() => {
supabase.from('companies').select('id, name').order('name').then(({ data }) => setClients(data || []));
fetchBooks();
}, []);
useEffect(() => {
const address = bookInfo.customerAddress.trim();
if (!address || autoMapAddress === address) return undefined;
if ((siteMapManuallyCleared || siteMapPath || (siteMapFile && !autoMapAddress))
&& (inventoryMapManuallyCleared || inventoryMapPath || (inventoryMapFile && !autoMapAddress))) return undefined;
const timeoutId = setTimeout(() => {
if (address.includes(',') && address.length >= 12) {
// Called through a ref so the debounce isn't reset by the handler's identity.
loadMapsFromAddressRef.current(address, { silent: true });
}
}, 900);
return () => clearTimeout(timeoutId);
}, [
bookInfo.customerAddress,
siteMapPath,
siteMapFile,
siteMapManuallyCleared,
inventoryMapPath,
inventoryMapFile,
inventoryMapManuallyCleared,
autoMapAddress,
]);
useEffect(() => {
const query = bookInfo.customerAddress.trim();
if (!MAPBOX_TOKEN || query.length < 3) {
setAddressSuggestions([]);
setAddressSuggesting(false);
return undefined;
}
let cancelled = false;
const timeoutId = setTimeout(async () => {
setAddressSuggesting(true);
try {
const suggestions = await getMapboxAddressSuggestions(query, 5);
if (!cancelled) setAddressSuggestions(suggestions);
} catch {
if (!cancelled) setAddressSuggestions([]);
} finally {
if (!cancelled) setAddressSuggesting(false);
}
}, 250);
return () => {
cancelled = true;
clearTimeout(timeoutId);
};
}, [bookInfo.customerAddress]);
const fetchBooks = async () => {
setLoadingBooks(true);
const { data } = await supabase.from('brand_books').select('*').order('updated_at', { ascending: false });
setSavedBooks(data || []);
setLoadingBooks(false);
};
const set = (field) => (e) => setBookInfo(b => ({ ...b, [field]: e.target.value }));
const handleRevisionChange = (e) => {
const digits = e.target.value.replace(/\D/g, '').slice(0, 2);
setBookInfo(b => ({ ...b, revision: digits }));
};
const handleRevisionBlur = () => {
setBookInfo(b => ({ ...b, revision: normalizeRevision(b.revision) }));
};
const loadMapsFromAddressRef = useRef(null);
const loadMapsFromAddress = async (address = bookInfo.customerAddress, { silent = false, feature = null } = {}) => {
const trimmed = String(address || '').trim();
if (!trimmed) {
if (!silent) setAutoMapError('Enter a customer address first.');
return;
}
if (!MAPBOX_TOKEN) {
if (!silent) setAutoMapError('Mapbox token is not configured yet. Add VITE_MAPBOX_TOKEN in Vercel to enable automatic site maps.');
return;
}
if (autoMapLoading) return;
setAutoMapLoading(true);
setAutoMapError('');
try {
const coordinates = feature?.center || await getMapboxCoordinates(trimmed);
const shouldLoadSiteMap = !siteMapManuallyCleared || !siteMapFile;
const shouldLoadInventoryMap = !inventoryMapManuallyCleared || !inventoryMapFile;
const [siteResponse, inventoryResponse] = await Promise.all([
shouldLoadSiteMap ? fetch(buildStaticMapUrl(coordinates, 'satellite-streets-v12')) : Promise.resolve(null),
shouldLoadInventoryMap ? fetch(buildStaticMapUrl(coordinates, 'streets-v12', 18.2)) : Promise.resolve(null),
]);
if ((siteResponse && !siteResponse.ok) || (inventoryResponse && !inventoryResponse.ok)) throw new Error('Unable to load map image.');
const [siteBlob, inventoryBlob] = await Promise.all([
siteResponse ? siteResponse.blob() : Promise.resolve(null),
inventoryResponse ? inventoryResponse.blob() : Promise.resolve(null),
]);
if ((siteBlob && !siteBlob.type.startsWith('image/')) || (inventoryBlob && !inventoryBlob.type.startsWith('image/'))) throw new Error('Map service did not return an image.');
const fullAddress = feature?.place_name || trimmed;
setBookInfo(b => ({ ...b, customerAddress: fullAddress, siteAddress: fullAddress }));
if (siteBlob) {
const siteFile = new File([siteBlob], 'site-map.png', { type: siteBlob.type || 'image/png' });
setSiteMapFile(siteFile);
setSiteMapPath('');
setSiteMapPreview(URL.createObjectURL(siteFile));
setSiteMapManuallyCleared(false);
}
if (inventoryBlob) {
const inventoryFile = new File([inventoryBlob], 'inventory-map.png', { type: inventoryBlob.type || 'image/png' });
setInventoryMapFile(inventoryFile);
setInventoryMapPath('');
setInventoryMapPreview(URL.createObjectURL(inventoryFile));
setInventoryMapManuallyCleared(false);
}
setAutoMapAddress(fullAddress);
setShowAddressSuggestions(false);
} catch (error) {
setAutoMapError(error.message || 'Unable to load map for this address.');
} finally {
setAutoMapLoading(false);
}
};
useEffect(() => {
loadMapsFromAddressRef.current = loadMapsFromAddress;
});
const handleCustomerAddressChange = (e) => {
const value = e.target.value;
setBookInfo(b => ({ ...b, customerAddress: value, siteAddress: value }));
setAutoMapError('');
setSiteMapManuallyCleared(false);
setInventoryMapManuallyCleared(false);
setShowAddressSuggestions(true);
};
const handleSelectAddressSuggestion = (feature) => {
const fullAddress = feature.place_name || '';
setBookInfo(b => ({ ...b, customerAddress: fullAddress, siteAddress: fullAddress }));
setAddressSuggestions([]);
setShowAddressSuggestions(false);
loadMapsFromAddress(fullAddress, { feature });
};
const clearSiteMap = () => {
setSiteMapFile(null);
setSiteMapPath('');
setSiteMapPreview(null);
setSiteMapManuallyCleared(true);
};
const clearInventoryMap = () => {
setInventoryMapFile(null);
setInventoryMapPath('');
setInventoryMapPreview(null);
setInventoryMapManuallyCleared(true);
};
const handleClientChange = async (e) => {
const id = e.target.value;
const client = clients.find(c => c.id === id);
const autoTemplate = client?.name?.toLowerCase().includes('bolchoz') ? 'bolchoz' : 'fourge';
setBookInfo(b => ({
...b,
clientId: id,
clientName: client ? client.name : '',
customerName: b.customerName || '',
template: autoTemplate,
}));
if (id) {
const { data: co } = await supabase.from('companies').select('*').eq('id', id).single();
if (co) {
setBookInfo(b => ({
...b,
clientLogoUrl: co.client_logo_url || '',
clientContactName: co.contact_name || '',
clientContactEmail: co.contact_email || '',
clientContactPhone: co.contact_phone || '',
customerAddress: b.customerAddress || co.address || b.siteAddress || '',
siteAddress: b.siteAddress || b.customerAddress || co.address || '',
}));
}
}
};
const resetForm = () => {
setBookInfo(EMPTY_BOOK_INFO);
setSiteMapFile(null);
setSiteMapPath('');
setSiteMapPreview(null);
setAutoMapAddress('');
setAutoMapError('');
setSiteMapManuallyCleared(false);
setInventoryMapManuallyCleared(false);
setAddressSuggestions([]);
setShowAddressSuggestions(false);
setInventoryMapFile(null);
setInventoryMapPath('');
setInventoryMapPreview(null);
setSigns([EMPTY_SIGN()]);
setPhotoItems([]);
setCurrentId(null);
setNotification(null);
setProjectLogoFile(null);
setProjectLogoPath('');
setProjectLogoPreview('');
};
const handleNew = () => { resetForm(); setView('form'); };
const buildLoadedState = async (book) => {
const [siteMapSignedUrl, inventoryMapSignedUrl, projectLogoSignedUrl] = await Promise.all([
getSignedUrl(book.site_map_path),
getSignedUrl(book.inventory_map_path),
getSignedUrl(book.project_logo_path),
]);
const signsWithPreviews = await Promise.all((book.signs || []).map(async (sign) => {
const [preview, existingPreview, recommendationPreview, signDetailPreview] = await Promise.all([
getSignedUrl(sign.photoPath),
getSignedUrl(sign.existingPhotoPath),
getSignedUrl(sign.recommendationPhotoPath),
getSignedUrl(sign.signDetailPhotoPath),
]);
return {
...sign,
photo: null,
_photoPreview: preview || '',
existingPhoto: null,
_existingPhotoPreview: existingPreview || '',
recommendationPhoto: null,
_recommendationPhotoPreview: recommendationPreview || '',
signDetailPhoto: null,
_signDetailPhotoPreview: signDetailPreview || '',
};
}));
const surveyItems = await Promise.all((book.survey_photo_paths || []).map(async (path) => {
const preview = await getSignedUrl(path);
return { file: null, path, preview: preview || '' };
}));
return { siteMapSignedUrl, inventoryMapSignedUrl, projectLogoSignedUrl, signsWithPreviews, surveyItems };
};
const handleLoad = async (book) => {
setNotification(null);
const { siteMapSignedUrl, inventoryMapSignedUrl, projectLogoSignedUrl, signsWithPreviews, surveyItems } = await buildLoadedState(book);
setBookInfo({
clientId: book.client_id || '',
clientName: book.client_name || '',
projectName: book.project_name || '',
siteAddress: book.site_address || '',
bookDate: book.book_date || new Date().toISOString().split('T')[0],
preparedBy: book.prepared_by || '',
revision: normalizeRevision(book.revision || '01'),
template: book.template || 'fourge',
creationDate: book.creation_date || new Date().toISOString().slice(0, 10),
revisionDate: book.revision_date || '',
customerName: book.customer_name || '',
customerAddress: book.customer_address || book.site_address || '',
clientLogoUrl: book.client_logo_url || '',
clientContactName: book.client_contact_name || '',
clientContactEmail: book.client_contact_email || '',
clientContactPhone: book.client_contact_phone || '',
approvedDate: book.approved_date || '',
approvalNotes: book.approval_notes || '',
});
setSiteMapFile(null);
setSiteMapPath(book.site_map_path || '');
setSiteMapPreview(siteMapSignedUrl);
setInventoryMapFile(null);
setInventoryMapPath(book.inventory_map_path || '');
setInventoryMapPreview(inventoryMapSignedUrl);
setProjectLogoFile(null);
setProjectLogoPath(book.project_logo_path || '');
setProjectLogoPreview(projectLogoSignedUrl || '');
setSigns(signsWithPreviews.length > 0 ? signsWithPreviews : [EMPTY_SIGN()]);
setPhotoItems(surveyItems);
setCurrentId(book.id);
setView('form');
};
const handleDelete = async (book) => {
if (!window.confirm('Delete this brand book? This cannot be undone.')) return;
await cleanupBrandBookStorage(book);
await supabase.from('brand_books').delete().eq('id', book.id);
fetchBooks();
};
const handleSave = async () => {
if (!bookInfo.clientName.trim()) {
setNotification({ type: 'error', msg: 'Please select a company.' });
return;
}
setSaving(true);
setNotification(null);
try {
const bookId = currentId || crypto.randomUUID();
// Upload project logo if new file
let finalProjectLogoPath = projectLogoPath;
if (projectLogoFile) {
const ext = projectLogoFile.name.split('.').pop().toLowerCase();
finalProjectLogoPath = `${bookId}/project-logo.${ext}`;
await uploadFile(projectLogoFile, finalProjectLogoPath);
}
// Upload site map if new file
let finalSiteMapPath = siteMapPath;
if (siteMapFile) {
const ext = siteMapFile.name.split('.').pop().toLowerCase();
finalSiteMapPath = `${bookId}/site-map.${ext}`;
await uploadFile(siteMapFile, finalSiteMapPath);
}
// Upload inventory map if new file
let finalInventoryMapPath = inventoryMapPath;
if (inventoryMapFile) {
const ext = inventoryMapFile.name.split('.').pop().toLowerCase();
finalInventoryMapPath = `${bookId}/inventory-map.${ext}`;
await uploadFile(inventoryMapFile, finalInventoryMapPath);
}
// Upload sign photos if new file
const finalSigns = await Promise.all(signs.map(async (sign) => {
let photoPath = sign.photoPath || '';
if (sign.photo) {
const ext = sign.photo.name.split('.').pop().toLowerCase();
photoPath = `${bookId}/sign-${sign._key}.${ext}`;
await uploadFile(sign.photo, photoPath);
}
let existingPhotoPath = sign.existingPhotoPath || '';
if (sign.existingPhoto) {
const ext = sign.existingPhoto.name.split('.').pop().toLowerCase();
existingPhotoPath = `${bookId}/sign-${sign._key}-existing.${ext}`;
await uploadFile(sign.existingPhoto, existingPhotoPath);
}
let recommendationPhotoPath = sign.recommendationPhotoPath || '';
if (sign.recommendationPhoto) {
const ext = sign.recommendationPhoto.name.split('.').pop().toLowerCase();
recommendationPhotoPath = `${bookId}/sign-${sign._key}-recommendation.${ext}`;
await uploadFile(sign.recommendationPhoto, recommendationPhotoPath);
}
let signDetailPhotoPath = sign.signDetailPhotoPath || '';
if (sign.signDetailPhoto) {
const ext = sign.signDetailPhoto.name.split('.').pop().toLowerCase();
signDetailPhotoPath = `${bookId}/sign-${sign._key}-detail.${ext}`;
await uploadFile(sign.signDetailPhoto, signDetailPhotoPath);
}
const {
photo: _photo,
_photoPreview,
existingPhoto: _existingPhoto,
_existingPhotoPreview,
recommendationPhoto: _recommendationPhoto,
_recommendationPhotoPreview,
signDetailPhoto: _signDetailPhoto,
_signDetailPhotoPreview,
...rest
} = sign;
return { ...rest, photoPath, existingPhotoPath, recommendationPhotoPath, signDetailPhotoPath };
}));
// Upload survey photos if new file
const finalSurveyPaths = await Promise.all(photoItems.map(async (item, i) => {
if (item.file) {
const ext = item.file.name.split('.').pop().toLowerCase();
const path = `${bookId}/survey-${i}-${Date.now()}.${ext}`;
await uploadFile(item.file, path);
return path;
}
return item.path;
}));
const dbData = {
id: bookId,
client_id: bookInfo.clientId || null,
client_name: bookInfo.clientName,
project_name: bookInfo.projectName,
site_address: bookInfo.siteAddress,
book_date: bookInfo.bookDate || null,
prepared_by: bookInfo.preparedBy,
revision: normalizeRevision(bookInfo.revision),
template: bookInfo.template || 'fourge',
site_map_path: finalSiteMapPath,
inventory_map_path: finalInventoryMapPath || null,
signs: finalSigns,
survey_photo_paths: finalSurveyPaths.filter(Boolean),
updated_at: new Date().toISOString(),
// Cover page fields
project_logo_path: finalProjectLogoPath || null,
creation_date: bookInfo.creationDate || null,
revision_date: bookInfo.revisionDate || null,
customer_name: bookInfo.customerName || null,
customer_address: bookInfo.customerAddress || null,
client_logo_url: bookInfo.clientLogoUrl || null,
client_contact_name: bookInfo.clientContactName || null,
client_contact_email: bookInfo.clientContactEmail || null,
client_contact_phone: bookInfo.clientContactPhone || null,
approved_date: bookInfo.approvedDate || null,
approval_notes: bookInfo.approvalNotes || null,
};
const { error: upsertError } = await supabase.from('brand_books').upsert(dbData);
if (upsertError) throw upsertError;
const savedSignsWithPreviews = await Promise.all(finalSigns.map(async (sign) => {
const [photoPreview, existingPhotoPreview, recommendationPhotoPreview, signDetailPhotoPreview] = await Promise.all([
getSignedUrl(sign.photoPath),
getSignedUrl(sign.existingPhotoPath),
getSignedUrl(sign.recommendationPhotoPath),
getSignedUrl(sign.signDetailPhotoPath),
]);
return {
...sign,
photo: null,
existingPhoto: null,
recommendationPhoto: null,
signDetailPhoto: null,
_photoPreview: photoPreview || '',
_existingPhotoPreview: existingPhotoPreview || '',
_recommendationPhotoPreview: recommendationPhotoPreview || '',
_signDetailPhotoPreview: signDetailPhotoPreview || '',
};
}));
setCurrentId(bookId);
setSiteMapPath(finalSiteMapPath);
setSiteMapFile(null);
setInventoryMapPath(finalInventoryMapPath);
setInventoryMapFile(null);
setProjectLogoPath(finalProjectLogoPath);
setProjectLogoFile(null);
setSigns(savedSignsWithPreviews);
setPhotoItems(finalSurveyPaths.filter(Boolean).map((path, i) => ({
file: null,
path,
preview: photoItems[i]?.preview || '',
})));
await fetchBooks();
setNotification({ type: 'success', msg: '✓ Brand book saved!' });
} catch (err) {
setNotification({ type: 'error', msg: `Save failed: ${err.message}` });
} finally {
setSaving(false);
}
};
const handleGenerate = async () => {
if (!bookInfo.clientName.trim()) {
setNotification({ type: 'error', msg: 'Please select a company.' });
return;
}
setGenerating(true);
setNotification(null);
try {
const [siteMapSource, inventoryMapSource] = await Promise.all([
siteMapFile ? Promise.resolve(siteMapFile) : (siteMapPath ? getSignedUrl(siteMapPath) : null),
inventoryMapFile ? Promise.resolve(inventoryMapFile) : (inventoryMapPath ? getSignedUrl(inventoryMapPath) : null),
]);
const signsWithSource = await Promise.all(signs.map(async s => ({
...s,
photoSource: s.photo || (s.photoPath ? await getSignedUrl(s.photoPath) : null),
existingPhotoSource: s.existingPhoto || (s.existingPhotoPath ? await getSignedUrl(s.existingPhotoPath) : null),
recommendationPhotoSource: s.recommendationPhoto || (s.recommendationPhotoPath ? await getSignedUrl(s.recommendationPhotoPath) : null),
signDetailPhotoSource: s.signDetailPhoto || (s.signDetailPhotoPath ? await getSignedUrl(s.signDetailPhotoPath) : null),
})));
const sitePhotoSources = await Promise.all(photoItems.map(async item =>
item.file || (item.path ? await getSignedUrl(item.path) : null)
));
const projectLogoSource = projectLogoFile || (projectLogoPath ? await getSignedUrl(projectLogoPath) : null);
await generateBrandBookEditorPDF({
template: bookInfo.template || 'fourge',
clientName: bookInfo.clientName,
projectName: bookInfo.projectName,
siteAddress: bookInfo.siteAddress,
bookDate: bookInfo.bookDate,
preparedBy: bookInfo.preparedBy,
revision: normalizeRevision(bookInfo.revision),
siteMapSource,
inventoryMapSource,
signs: signsWithSource,
sitePhotoSources,
// Cover page
projectLogoSource,
creationDate: bookInfo.creationDate,
revisionDate: bookInfo.revisionDate,
customerName: bookInfo.customerName,
customerAddress: bookInfo.customerAddress || bookInfo.siteAddress,
clientLogoSource: bookInfo.clientLogoUrl || null,
clientContactName: bookInfo.clientContactName,
clientContactEmail: bookInfo.clientContactEmail,
clientContactPhone: bookInfo.clientContactPhone,
approvedDate: bookInfo.approvedDate,
approvalNotes: bookInfo.approvalNotes,
});
setNotification({ type: 'success', msg: '✓ Brand book PDF downloaded!' });
} catch (err) {
setNotification({ type: 'error', msg: `Failed to generate PDF: ${err.message}` });
} finally {
setGenerating(false);
}
};
// ── Project logo helpers ──────────────────────────────────────────────────────
const handleProjectLogoUpload = (e) => {
const file = e.target.files[0];
if (!file) return;
setProjectLogoFile(file);
setProjectLogoPreview(URL.createObjectURL(file));
setProjectLogoPath('');
};
// ── Client logo / info helpers ────────────────────────────────────────────────
const handleClientLogoUpload = async (e) => {
const file = e.target.files[0];
if (!file) return;
if (!bookInfo.clientId) { setNotification({ type: 'error', msg: 'Select a company first.' }); return; }
setUploadingClientLogo(true);
const ext = file.name.split('.').pop().toLowerCase();
const path = `${bookInfo.clientId}/logo.${ext}`;
await supabase.storage.from('company-logos').remove([path]);
const { error } = await supabase.storage.from('company-logos').upload(path, file, { upsert: true });
if (!error) {
const { data: { publicUrl } } = supabase.storage.from('company-logos').getPublicUrl(path);
await supabase.from('companies').update({ client_logo_url: publicUrl }).eq('id', bookInfo.clientId);
setBookInfo(b => ({ ...b, clientLogoUrl: publicUrl }));
}
setUploadingClientLogo(false);
};
const handleSaveClientInfo = async () => {
if (!bookInfo.clientId) return;
setSavingClientInfo(true);
await supabase.from('companies').update({
address: bookInfo.customerAddress || null,
contact_name: bookInfo.clientContactName || null,
contact_email: bookInfo.clientContactEmail || null,
contact_phone: bookInfo.clientContactPhone || null,
}).eq('id', bookInfo.clientId);
setSavingClientInfo(false);
setClientInfoSaved(true);
setTimeout(() => setClientInfoSaved(false), 2500);
};
// ── Sign helpers ─────────────────────────────────────────────────────────────
const updateSign = (key, field, value) =>
setSigns(prev => prev.map(s => s._key === key ? { ...s, [field]: value } : s));
const addSign = () => { setSigns(prev => [...prev, EMPTY_SIGN()]); };
const removeSign = (key) => setSigns(prev => prev.filter(s => s._key !== key));
const handleSignPhoto = (key, field, file) => {
const previewField = `_${field}Preview`;
const pathField = `${field}Path`;
updateSign(key, field, file);
updateSign(key, previewField, URL.createObjectURL(file));
updateSign(key, pathField, '');
};
// ── Map helpers ───────────────────────────────────────────────────────────────
const handleSiteMapFile = (file) => {
setSiteMapFile(file);
setSiteMapPath('');
setSiteMapPreview(URL.createObjectURL(file));
setAutoMapAddress('');
setAutoMapError('');
setSiteMapManuallyCleared(false);
};
const handleInventoryMapFile = (file) => {
setInventoryMapFile(file);
setInventoryMapPath('');
setInventoryMapPreview(URL.createObjectURL(file));
setAutoMapAddress('');
setAutoMapError('');
setInventoryMapManuallyCleared(false);
};
// ── Survey photo helpers ──────────────────────────────────────────────────────
const handleSitePhotos = (files) => {
const newItems = Array.from(files)
.filter(isPhotoFile)
.map(file => ({ file, path: '', preview: URL.createObjectURL(file) }));
setPhotoItems(prev => [...prev, ...newItems]);
};
const removeSitePhoto = (i) => setPhotoItems(prev => prev.filter((_, idx) => idx !== i));
// ── Unsaved changes indicator ─────────────────────────────────────────────────
const hasUnsavedPhotos = siteMapFile || inventoryMapFile || signs.some(s => (
s.photo || s.existingPhoto || s.recommendationPhoto || s.signDetailPhoto
)) || photoItems.some(i => i.file);
// ═══════════════════════════════════════════════════════════════════════════════
// LIST VIEW
// ═══════════════════════════════════════════════════════════════════════════════
if (view === 'list') {
const companyNames = [...new Set(savedBooks.map(book => book.client_name || clients.find(client => client.id === book.client_id)?.name).filter(Boolean))].sort();
const filteredBooks = savedBooks.filter(book => {
if (!filterCompany) return true;
const clientName = book.client_name || clients.find(client => client.id === book.client_id)?.name;
return clientName === filterCompany;
});
return (
Brand Book Maker
Saved brand books — click to edit or add a revision.
{companyNames.length > 0 && (
)}
{loadingBooks ? (
Loading...
) : filteredBooks.length === 0 ? (
{savedBooks.length === 0 ? 'No brand books' : 'No matching brand books'}
{savedBooks.length === 0 &&
}
) : (
Name
Revision
Sign Count
Company
Updated
|
{bbSort(filteredBooks, (book, key) => {
if (key === 'sign_count') return Array.isArray(book.signs) ? book.signs.length : 0;
if (key === 'client') return book.client_name || clients.find(c => c.id === book.client_id)?.name || '';
if (key === 'updated_at') return book.updated_at ? new Date(book.updated_at).getTime() : 0;
return book[key] || '';
}).map(book => {
const signCount = Array.isArray(book.signs) ? book.signs.length : 0;
const clientName = book.client_name || clients.find(client => client.id === book.client_id)?.name || 'No client';
const updated = book.updated_at
? new Date(book.updated_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
: '—';
return (
handleLoad(book)} style={{ cursor: 'pointer' }}>
| {book.project_name || 'Brand Book'} |
{`R${String(book.revision || '01').padStart(2, '0')}`} |
{signCount} |
{clientName} |
{updated} |
e.stopPropagation()}>
|
);
})}
)}
);
}
// ═══════════════════════════════════════════════════════════════════════════════
// FORM VIEW
// ═══════════════════════════════════════════════════════════════════════════════
return (
{currentId
? `${bookInfo.projectName || 'Brand Book'} R${String(bookInfo.revision).padStart(2, '0')}`
: 'New Brand Book'}
{currentId ? 'Editing saved brand book' : 'Unsaved — fill in details and save'}
{hasUnsavedPhotos && · Unsaved photos}
Generate PDF
{notification && (
{notification.msg}
)}
{/* ── BRAND BOOK INFO ──────────────────────────────────────────────── */}
Brand Book Info
{/* ── COVER PAGE ───────────────────────────────────────────────────── */}
Cover Page
{/* Project logo */}
{projectLogoPreview && (

)}
{projectLogoPreview && (
)}
{/* Dates */}
{/* Customer info */}
setShowAddressSuggestions(true)}
style={{ margin: 0 }}
/>
loadMapsFromAddress()}
>
Load Maps
{showAddressSuggestions && (addressSuggestions.length > 0 || addressSuggesting) && (
{addressSuggesting && (
Searching...
)}
{addressSuggestions.map(feature => (
))}
)}
{autoMapError && (
{autoMapError}
)}
{/* Client info (saved per company) */}
Company Info
Logo and contact saved to company — reused across all brand books.
{bookInfo.clientLogoUrl && (

)}
{!bookInfo.clientId && Select a client first}
{/* Approval */}
Approval
{/* ── SITE MAP ──────────────────────────────────────────────────────── */}
Site Map
{/* ── INVENTORY MAP ─────────────────────────────────────────────────── */}
Site Map
{/* ── SIGNS ─────────────────────────────────────────────────────────── */}
{signs.map((sign, i) => (
updateSign(sign._key, field, val)}
onPhotoChange={(field, file) => handleSignPhoto(sign._key, field, file)}
onRemove={() => removeSign(sign._key)}
canRemove={signs.length > 1}
template={bookInfo.template}
/>
))}
{/* ── SITE PHOTOS ───────────────────────────────────────────────────── */}
Site Photos
{/* Bottom actions */}
Generate PDF
{notification && (
{notification.msg}
)}
);
}
// ─── Book List Item ───────────────────────────────────────────────────────────
// ─── Sign Card ────────────────────────────────────────────────────────────────
function SignCard({ sign, index, onChange, onPhotoChange, onRemove, canRemove, template }) {
const photoInputRef = useRef();
const existingPhotoInputRef = useRef();
const recommendationPhotoInputRef = useRef();
const signDetailPhotoInputRef = useRef();
const dragCounter = useRef(0);
const [dragging, setDragging] = useState(false);
const handleDragEnter = (e) => { e.preventDefault(); dragCounter.current++; setDragging(true); };
const handleDragLeave = (e) => { e.preventDefault(); dragCounter.current--; if (dragCounter.current === 0) setDragging(false); };
const handleDragOver = (e) => e.preventDefault();
const handleDrop = (field) => (e) => {
e.preventDefault(); dragCounter.current = 0; setDragging(false);
const file = e.dataTransfer.files[0];
if (isPhotoFile(file)) onPhotoChange(field, file);
};
const summary = [sign.type, sign.recommendation].filter(Boolean).join(' — ') || 'New Sign';
const hasPhoto = sign._photoPreview || sign._existingPhotoPreview || sign._recommendationPhotoPreview || sign._signDetailPhotoPreview;
return (
#{sign.signNumber || (index + 1)}
{summary}
{hasPhoto && 📷}
{sign.photo && unsaved photo}
{canRemove && (
✕
)}
{template === 'bolchoz' ? (
onPhotoChange('existingPhoto', file)}
/>
onPhotoChange('recommendationPhoto', file)}
onSignDetailPick={(file) => onPhotoChange('signDetailPhoto', file)}
/>
) : (
onPhotoChange('photo', file)}
/>
)}
);
}
function PhotoField({ label, preview, fileName, dragging, inputRef, onDragEnter, onDragLeave, onDragOver, onDrop, onPick }) {
return (
inputRef.current?.click()}
style={{
border: `2px dashed ${dragging ? 'var(--accent)' : 'var(--border)'}`,
borderRadius: 4,
background: dragging ? 'color-mix(in srgb, var(--accent) 5%, transparent)' : 'var(--input-bg, var(--card-bg))',
padding: 12,
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: 12,
transition: 'border-color 0.15s, background 0.15s',
minHeight: 60,
}}
>
{preview ? (
<>
{fileName || 'Saved photo'}
Click to replace · drag a new photo
>
) : (
{dragging ? '📂 Drop photo here' : '📷 Click to upload or drag & drop a photo'}
)}
{ const f = e.target.files[0]; if (f) onPick(f); e.target.value = ''; }}
/>
);
}
// ─── Site Map Drop Zone ───────────────────────────────────────────────────────
function SiteMapDropZone({ preview, onFile, onClear, inputRef }) {
const dragCounter = useRef(0);
const [dragging, setDragging] = useState(false);
const handleDragEnter = (e) => { e.preventDefault(); dragCounter.current++; setDragging(true); };
const handleDragLeave = (e) => { e.preventDefault(); dragCounter.current--; if (dragCounter.current === 0) setDragging(false); };
const handleDragOver = (e) => e.preventDefault();
const handleDrop = (e) => {
e.preventDefault(); dragCounter.current = 0; setDragging(false);
const file = e.dataTransfer.files[0];
if (isPhotoFile(file)) onFile(file);
};
if (preview) {
return (
);
}
return (
<>
inputRef.current?.click()}
style={{
border: `2px dashed ${dragging ? 'var(--accent)' : 'var(--border)'}`,
borderRadius: 4,
background: dragging ? 'color-mix(in srgb, var(--accent) 5%, transparent)' : 'var(--input-bg, var(--card-bg))',
padding: '24px 16px', textAlign: 'center', cursor: 'pointer',
color: dragging ? 'var(--accent)' : 'var(--text-muted)', fontSize: 13, transition: 'all 0.15s',
}}
>
{dragging ? '📂 Drop site map here' : '🗺 Click to upload or drag & drop a site map image'}
{ const f = e.target.files[0]; if (f) onFile(f); e.target.value = ''; }} />
>
);
}
// ─── Survey Photos Drop Zone ──────────────────────────────────────────────────
function SitePhotosDropZone({ photoItems, onFiles, onRemove, inputRef }) {
const dragCounter = useRef(0);
const [dragging, setDragging] = useState(false);
const handleDragEnter = (e) => { e.preventDefault(); dragCounter.current++; setDragging(true); };
const handleDragLeave = (e) => { e.preventDefault(); dragCounter.current--; if (dragCounter.current === 0) setDragging(false); };
const handleDragOver = (e) => e.preventDefault();
const handleDrop = (e) => { e.preventDefault(); dragCounter.current = 0; setDragging(false); onFiles(e.dataTransfer.files); };
return (
inputRef.current?.click()}
style={{
border: `2px dashed ${dragging ? 'var(--accent)' : 'var(--border)'}`,
borderRadius: 4,
background: dragging ? 'color-mix(in srgb, var(--accent) 5%, transparent)' : 'var(--input-bg, var(--card-bg))',
padding: '20px 16px', textAlign: 'center', cursor: 'pointer',
color: dragging ? 'var(--accent)' : 'var(--text-muted)', fontSize: 13,
marginBottom: photoItems.length > 0 ? 12 : 0, transition: 'all 0.15s',
}}
>
{dragging ? '📂 Drop photos here' : '📷 Click to upload or drag & drop photos (select multiple)'}
{ if (e.target.files.length) { onFiles(e.target.files); e.target.value = ''; } }} />
{photoItems.length > 0 && (
{photoItems.map((item, i) => (

{item.file && (
NEW
)}
))}
{photoItems.length} photo{photoItems.length !== 1 ? 's' : ''}
{photoItems.length > 12 && (first 12 shown in PDF)}
)}
);
}
// ─── Combined Mockup Photo Field (one editor for both outputs) ────────────────
function CombinedMockupPhotoField({
recommendationPreview,
recommendationFileName,
signDetailPreview,
signDetailFileName,
baseSourceImage,
dragging,
recommendationInputRef,
signDetailInputRef,
onDragEnter,
onDragLeave,
onDragOver,
onRecommendationDrop,
onSignDetailDrop,
onRecommendationPick,
onSignDetailPick,
}) {
const [showEditor, setShowEditor] = useState(false);
const targetSource = (target) => {
if (target === 'recommendation') return recommendationPreview || baseSourceImage;
return signDetailPreview || recommendationPreview || baseSourceImage;
};
const tileStyle = {
border: `2px dashed ${dragging ? 'var(--accent)' : 'var(--border)'}`,
borderRadius: 4,
background: dragging ? 'color-mix(in srgb, var(--accent) 5%, transparent)' : 'var(--input-bg, var(--card-bg))',
padding: 12,
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: 12,
transition: 'border-color 0.15s, background 0.15s',
minHeight: 60,
position: 'relative',
};
const renderTile = ({ label, preview, fileName, inputRef, onDrop, onPick }) => (
setShowEditor(true)}
style={tileStyle}
>
{preview ? (
<>
{fileName || 'Saved photo'}
Click to open shared editor
>
) : (
{dragging ? 'Drop photo here' : `${label}: open editor or upload`}
)}
{ const f = e.target.files[0]; if (f) onPick(f); e.target.value = ''; }}
/>
);
return (
{renderTile({
label: 'Recommendation Photo',
preview: recommendationPreview,
fileName: recommendationFileName,
inputRef: recommendationInputRef,
onDrop: onRecommendationDrop,
onPick: onRecommendationPick,
})}
{renderTile({
label: 'Sign Detail Photo',
preview: signDetailPreview,
fileName: signDetailFileName,
inputRef: signDetailInputRef,
onDrop: onSignDetailDrop,
onPick: onSignDetailPick,
})}
{showEditor && (
{
if (targetId === 'signDetail') onSignDetailPick(file);
else onRecommendationPick(file);
}}
onCancel={() => setShowEditor(false)}
/>
)}
);
}
// ─── Recommendation Photo Field (click → editor, drag → direct upload) ────────
function RecommendationPhotoField({ preview, fileName, dragging, inputRef, onDragEnter, onDragLeave, onDragOver, onDrop, onPick, editorSourceImage }) {
const [showEditor, setShowEditor] = useState(false);
const handleUploadClick = (e) => {
e.stopPropagation();
inputRef.current?.click();
};
return (
setShowEditor(true)}
style={{
border: `2px dashed ${dragging ? 'var(--accent)' : 'var(--border)'}`,
borderRadius: 4,
background: dragging ? 'color-mix(in srgb, var(--accent) 5%, transparent)' : 'var(--input-bg, var(--card-bg))',
padding: 12,
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: 12,
transition: 'border-color 0.15s, background 0.15s',
minHeight: 60,
position: 'relative',
}}
>
{preview ? (
<>
{fileName || 'Saved photo'}
Click to edit · drag a new photo
>
) : (
{dragging ? '📂 Drop photo here' : '✏️ Click to edit existing photo · or drag & drop'}
)}
{/* Upload button sits in corner so it doesn't trigger the editor */}
{ const f = e.target.files[0]; if (f) onPick(f); e.target.value = ''; }}
/>
{showEditor && (
{ onPick(file); setShowEditor(false); }}
onCancel={() => setShowEditor(false)}
/>
)}
);
}
// ─── Sign Detail Photo Field (click → dimension editor, drag → upload) ───────
function SignDetailPhotoField({ preview, fileName, dragging, inputRef, onDragEnter, onDragLeave, onDragOver, onDrop, onPick }) {
const [showEditor, setShowEditor] = useState(false);
const handleUploadClick = (e) => {
e.stopPropagation();
inputRef.current?.click();
};
return (
preview && setShowEditor(true)}
style={{
border: `2px dashed ${dragging ? 'var(--accent)' : 'var(--border)'}`,
borderRadius: 4,
background: dragging ? 'color-mix(in srgb, var(--accent) 5%, transparent)' : 'var(--input-bg, var(--card-bg))',
padding: 12,
cursor: preview ? 'pointer' : 'default',
display: 'flex',
alignItems: 'center',
gap: 12,
transition: 'border-color 0.15s, background 0.15s',
minHeight: 60,
position: 'relative',
}}
>
{preview ? (
<>
{fileName || 'Saved photo'}
Click to add dimensions · drag a new photo
>
) : (
{dragging ? '📂 Drop photo here' : '📐 Upload a sign detail photo to add dimensions'}
)}
{ const f = e.target.files[0]; if (f) onPick(f); e.target.value = ''; }}
/>
{showEditor && (
{ onPick(file); setShowEditor(false); }}
onCancel={() => setShowEditor(false)}
/>
)}
);
}
// ─── Dimension Editor ────────────────────────────────────────────────────────
function DimensionEditorModal({ sourceImage, onApply, onCancel }) {
const canvasRef = useRef(null);
const [baseImage, setBaseImage] = useState(null);
const [loaded, setLoaded] = useState(false);
const [dimensions, setDimensions] = useState([]);
const [draftPoints, setDraftPoints] = useState([]);
const [draftOffsetPoint, setDraftOffsetPoint] = useState(null);
const [isPlacingOffset, setIsPlacingOffset] = useState(false);
const [dimensionMode, setDimensionMode] = useState('line');
const [boxStart, setBoxStart] = useState(null);
const [boxEnd, setBoxEnd] = useState(null);
const [activeDimensionId, setActiveDimensionId] = useState(null);
const [dimensionText, setDimensionText] = useState('');
const [boxWidthText, setBoxWidthText] = useState('');
const [boxHeightText, setBoxHeightText] = useState('');
const activeDimension = dimensions.find(item => item.id === activeDimensionId) || null;
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => {
const MAX_W = 960;
const MAX_H = 580;
const scale = Math.min(MAX_W / img.naturalWidth, MAX_H / img.naturalHeight, 1);
canvas.width = Math.round(img.naturalWidth * scale);
canvas.height = Math.round(img.naturalHeight * scale);
setBaseImage(img);
setLoaded(true);
};
img.onerror = () => {
canvas.width = 800;
canvas.height = 500;
setLoaded(true);
};
img.src = sourceImage;
}, [sourceImage]);
useEffect(() => {
renderDimensionCanvas(true);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [baseImage, dimensions, draftPoints, draftOffsetPoint, activeDimensionId, boxStart, boxEnd]);
const getCanvasPos = (event) => {
const canvas = canvasRef.current;
const rect = canvas.getBoundingClientRect();
const scaleX = canvas.width / rect.width;
const scaleY = canvas.height / rect.height;
const src = event.touches ? event.touches[0] : event;
return {
x: (src.clientX - rect.left) * scaleX,
y: (src.clientY - rect.top) * scaleY,
};
};
function drawDimension(ctx, item, selected = false) {
const color = '#000000';
const line = getDimensionLine(item);
ctx.save();
ctx.strokeStyle = color;
ctx.fillStyle = color;
const dimensionLineWidth = selected ? 3 : 2.25;
ctx.lineWidth = dimensionLineWidth;
// Extension lines from picked object points to the offset dimension line.
[item.start, item.end].forEach((point, index) => {
const target = index === 0 ? line.start : line.end;
ctx.beginPath();
ctx.moveTo(point.x, point.y);
ctx.lineTo(target.x, target.y);
ctx.stroke();
});
ctx.beginPath();
ctx.moveTo(line.start.x, line.start.y);
ctx.lineTo(line.end.x, line.end.y);
ctx.stroke();
const tickLength = 18;
const tickAngle = (135 * Math.PI) / 180;
ctx.lineWidth = dimensionLineWidth * 2;
[line.start, line.end].forEach((point) => {
ctx.beginPath();
ctx.moveTo(point.x - Math.cos(tickAngle) * tickLength / 2, point.y - Math.sin(tickAngle) * tickLength / 2);
ctx.lineTo(point.x + Math.cos(tickAngle) * tickLength / 2, point.y + Math.sin(tickAngle) * tickLength / 2);
ctx.stroke();
});
ctx.lineWidth = dimensionLineWidth;
const midX = (line.start.x + line.end.x) / 2;
const midY = (line.start.y + line.end.y) / 2;
ctx.font = '700 21px Helvetica, Arial, sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
const label = item.label || 'Dimension';
const width = ctx.measureText(label).width;
const labelPaddingX = 10;
const labelHeight = 30;
const labelWidth = width + labelPaddingX * 2;
const dx = line.end.x - line.start.x;
const dy = line.end.y - line.start.y;
const lineLength = Math.hypot(dx, dy);
const isMostlyHorizontal = Math.abs(dx) >= Math.abs(dy);
const needsOutsideLabel = isMostlyHorizontal
? lineLength < labelWidth + 28
: lineLength < labelHeight + 28;
let labelX = midX;
let labelY = midY;
if (needsOutsideLabel) {
const leaderGap = 8;
if (isMostlyHorizontal) {
const leftPoint = line.start.x <= line.end.x ? line.start : line.end;
const rightPoint = line.start.x <= line.end.x ? line.end : line.start;
const leftRoom = leftPoint.x;
const rightRoom = ctx.canvas.width - rightPoint.x;
const placeLeft = leftRoom >= labelWidth + leaderGap || leftRoom >= rightRoom;
labelX = placeLeft
? leftPoint.x - leaderGap - labelWidth / 2
: rightPoint.x + leaderGap + labelWidth / 2;
labelY = midY;
const labelEdgeX = placeLeft ? labelX + labelWidth / 2 : labelX - labelWidth / 2;
ctx.beginPath();
ctx.moveTo(placeLeft ? leftPoint.x : rightPoint.x, midY);
ctx.lineTo(labelEdgeX, labelY);
ctx.stroke();
} else {
const topPoint = line.start.y <= line.end.y ? line.start : line.end;
const bottomPoint = line.start.y <= line.end.y ? line.end : line.start;
const topRoom = topPoint.y;
const bottomRoom = ctx.canvas.height - bottomPoint.y;
const placeTop = topRoom >= labelHeight + leaderGap || topRoom >= bottomRoom;
labelX = midX;
labelY = placeTop
? topPoint.y - leaderGap - labelHeight / 2
: bottomPoint.y + leaderGap + labelHeight / 2;
const labelEdgeY = placeTop ? labelY + labelHeight / 2 : labelY - labelHeight / 2;
ctx.beginPath();
ctx.moveTo(midX, placeTop ? topPoint.y : bottomPoint.y);
ctx.lineTo(labelX, labelEdgeY);
ctx.stroke();
}
}
ctx.fillStyle = '#ffffff';
ctx.fillRect(labelX - labelWidth / 2, labelY - labelHeight / 2, labelWidth, labelHeight);
ctx.fillStyle = color;
ctx.fillText(label, labelX, labelY);
ctx.restore();
}
function getDimensionLine(item) {
if (item.lineStart && item.lineEnd) {
return { start: item.lineStart, end: item.lineEnd };
}
return { start: item.start, end: item.end };
}
function buildOffsetDimension(start, end, offsetPoint, label) {
const dx = end.x - start.x;
const dy = end.y - start.y;
const len = Math.hypot(dx, dy) || 1;
const nx = -dy / len;
const ny = dx / len;
const mid = { x: (start.x + end.x) / 2, y: (start.y + end.y) / 2 };
const offset = (offsetPoint.x - mid.x) * nx + (offsetPoint.y - mid.y) * ny;
return {
id: crypto.randomUUID(),
start,
end,
lineStart: { x: start.x + nx * offset, y: start.y + ny * offset },
lineEnd: { x: end.x + nx * offset, y: end.y + ny * offset },
label,
};
}
function renderDimensionCanvas(showGuides) {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, canvas.width, canvas.height);
if (baseImage) {
ctx.drawImage(baseImage, 0, 0, canvas.width, canvas.height);
} else {
ctx.fillStyle = '#e5e5e5';
ctx.fillRect(0, 0, canvas.width, canvas.height);
}
dimensions.forEach(item => drawDimension(ctx, item, item.id === activeDimensionId));
if (showGuides && draftPoints.length > 0) {
ctx.fillStyle = '#000000';
draftPoints.forEach((point) => {
ctx.beginPath();
ctx.arc(point.x, point.y, 5, 0, Math.PI * 2);
ctx.fill();
});
}
if (showGuides && draftPoints.length === 2 && draftOffsetPoint) {
drawDimension(ctx, buildOffsetDimension(draftPoints[0], draftPoints[1], draftOffsetPoint, dimensionText || 'Dimension'), true);
}
if (showGuides && boxStart && boxEnd) {
const rect = getRectFromPoints(boxStart, boxEnd);
ctx.save();
ctx.strokeStyle = '#000000';
ctx.lineWidth = 2;
ctx.setLineDash([6, 4]);
ctx.strokeRect(rect.x, rect.y, rect.w, rect.h);
ctx.setLineDash([]);
ctx.restore();
}
}
function getRectFromPoints(a, b) {
const x = Math.min(a.x, b.x);
const y = Math.min(a.y, b.y);
return { x, y, w: Math.abs(b.x - a.x), h: Math.abs(b.y - a.y) };
}
function addBoxDimensions(rect) {
const offset = 28;
const groupId = crypto.randomUUID();
const widthDim = {
...buildOffsetDimension(
{ x: rect.x, y: rect.y },
{ x: rect.x + rect.w, y: rect.y },
{ x: rect.x + rect.w / 2, y: rect.y - offset },
boxWidthText || dimensionText || 'Width'
),
group: 'box',
groupId,
};
const heightDim = {
...buildOffsetDimension(
{ x: rect.x, y: rect.y },
{ x: rect.x, y: rect.y + rect.h },
{ x: rect.x - offset, y: rect.y + rect.h / 2 },
boxHeightText || dimensionText || 'Height'
),
group: 'box',
groupId,
};
setDimensions(prev => [...prev, widthDim, heightDim]);
setActiveDimensionId(widthDim.id);
}
function autoDetectBoxDimensions() {
if (!baseImage) return;
const canvas = canvasRef.current;
if (!canvas) return;
const detectCanvas = document.createElement('canvas');
const maxDetectW = 260;
const scale = Math.min(maxDetectW / canvas.width, 1);
detectCanvas.width = Math.max(1, Math.round(canvas.width * scale));
detectCanvas.height = Math.max(1, Math.round(canvas.height * scale));
const detectCtx = detectCanvas.getContext('2d', { willReadFrequently: true });
detectCtx.drawImage(baseImage, 0, 0, detectCanvas.width, detectCanvas.height);
const { width, height } = detectCanvas;
const image = detectCtx.getImageData(0, 0, width, height);
const data = image.data;
const columnCounts = new Uint16Array(width);
const rowCounts = new Uint16Array(height);
let nonWhiteCount = 0;
const marginX = Math.round(width * 0.01);
const marginY = Math.round(height * 0.01);
for (let y = marginY; y < height - marginY; y++) {
for (let x = marginX; x < width - marginX; x++) {
const p = (y * width + x) * 4;
const alpha = data[p + 3];
const red = data[p];
const green = data[p + 1];
const blue = data[p + 2];
const isWhite = red >= 245 && green >= 245 && blue >= 245;
if (alpha > 24 && !isWhite) {
columnCounts[x] += 1;
rowCounts[y] += 1;
nonWhiteCount += 1;
}
}
}
const minColumnHits = Math.max(2, Math.round(height * 0.006));
const minRowHits = Math.max(2, Math.round(width * 0.006));
let whiteBoundsMinX = -1;
let whiteBoundsMaxX = -1;
let whiteBoundsMinY = -1;
let whiteBoundsMaxY = -1;
for (let x = marginX; x < width - marginX; x++) {
if (columnCounts[x] >= minColumnHits) {
whiteBoundsMinX = x;
break;
}
}
for (let x = width - marginX - 1; x >= marginX; x--) {
if (columnCounts[x] >= minColumnHits) {
whiteBoundsMaxX = x;
break;
}
}
for (let y = marginY; y < height - marginY; y++) {
if (rowCounts[y] >= minRowHits) {
whiteBoundsMinY = y;
break;
}
}
for (let y = height - marginY - 1; y >= marginY; y--) {
if (rowCounts[y] >= minRowHits) {
whiteBoundsMaxY = y;
break;
}
}
const whiteBoundsW = whiteBoundsMaxX - whiteBoundsMinX;
const whiteBoundsH = whiteBoundsMaxY - whiteBoundsMinY;
const nonWhiteRatio = nonWhiteCount / ((width - marginX * 2) * (height - marginY * 2));
const hasUsefulWhiteBounds =
whiteBoundsMinX >= 0 &&
whiteBoundsMinY >= 0 &&
whiteBoundsW >= width * 0.03 &&
whiteBoundsH >= height * 0.03 &&
nonWhiteRatio >= 0.0005 &&
nonWhiteRatio <= 0.65 &&
(whiteBoundsW < width * 0.95 || whiteBoundsH < height * 0.95);
if (hasUsefulWhiteBounds) {
const padding = 4;
const rectX = Math.max(0, whiteBoundsMinX / scale - padding);
const rectY = Math.max(0, whiteBoundsMinY / scale - padding);
addBoxDimensions({
x: rectX,
y: rectY,
w: Math.min(canvas.width, whiteBoundsMaxX / scale + padding) - rectX,
h: Math.min(canvas.height, whiteBoundsMaxY / scale + padding) - rectY,
});
return;
}
const luminance = new Float32Array(width * height);
const gradients = [];
for (let i = 0; i < width * height; i++) {
const p = i * 4;
luminance[i] = data[p] * 0.299 + data[p + 1] * 0.587 + data[p + 2] * 0.114;
}
for (let y = 1; y < height - 1; y++) {
for (let x = 1; x < width - 1; x++) {
const i = y * width + x;
gradients.push(Math.abs(luminance[i] - luminance[i + 1]) + Math.abs(luminance[i] - luminance[i + width]));
}
}
if (!gradients.length) return;
const mean = gradients.reduce((sum, value) => sum + value, 0) / gradients.length;
const variance = gradients.reduce((sum, value) => sum + Math.pow(value - mean, 2), 0) / gradients.length;
const threshold = Math.max(24, Math.min(70, mean + Math.sqrt(variance) * 1.1));
const edges = new Uint8Array(width * height);
const borderX = Math.round(width * 0.03);
const borderY = Math.round(height * 0.03);
for (let y = borderY; y < height - borderY; y++) {
for (let x = borderX; x < width - borderX; x++) {
const i = y * width + x;
const gradient = Math.abs(luminance[i] - luminance[i + 1]) + Math.abs(luminance[i] - luminance[i + width]);
if (gradient >= threshold) edges[i] = 1;
}
}
const visited = new Uint8Array(width * height);
const centerX = width / 2;
const centerY = height / 2;
let best = null;
for (let y = borderY; y < height - borderY; y++) {
for (let x = borderX; x < width - borderX; x++) {
const startIndex = y * width + x;
if (!edges[startIndex] || visited[startIndex]) continue;
const stack = [startIndex];
visited[startIndex] = 1;
let count = 0;
let minX = x;
let maxX = x;
let minY = y;
let maxY = y;
while (stack.length) {
const index = stack.pop();
const px = index % width;
const py = Math.floor(index / width);
count += 1;
minX = Math.min(minX, px);
maxX = Math.max(maxX, px);
minY = Math.min(minY, py);
maxY = Math.max(maxY, py);
for (let oy = -1; oy <= 1; oy++) {
for (let ox = -1; ox <= 1; ox++) {
if (!ox && !oy) continue;
const nx = px + ox;
const ny = py + oy;
if (nx < borderX || nx >= width - borderX || ny < borderY || ny >= height - borderY) continue;
const nextIndex = ny * width + nx;
if (!edges[nextIndex] || visited[nextIndex]) continue;
visited[nextIndex] = 1;
stack.push(nextIndex);
}
}
}
const boxW = maxX - minX;
const boxH = maxY - minY;
if (count < 18 || boxW < width * 0.04 || boxH < height * 0.04) continue;
const boxCenterX = (minX + maxX) / 2;
const boxCenterY = (minY + maxY) / 2;
const centerPenalty = Math.hypot((boxCenterX - centerX) / width, (boxCenterY - centerY) / height) * 120;
const score = count + boxW * boxH * 0.02 - centerPenalty;
if (!best || score > best.score) best = { minX, maxX, minY, maxY, score };
}
}
if (!best) {
window.alert('Could not detect a clear sign/object area. Drag a box manually on the image.');
return;
}
const padding = 10;
const rect = {
x: Math.max(0, best.minX / scale - padding),
y: Math.max(0, best.minY / scale - padding),
w: Math.min(canvas.width, best.maxX / scale + padding) - Math.max(0, best.minX / scale - padding),
h: Math.min(canvas.height, best.maxY / scale + padding) - Math.max(0, best.minY / scale - padding),
};
addBoxDimensions(rect);
}
const findDimensionAtPoint = (point) => {
const distanceToSegment = (pointValue, start, end) => {
const dx = end.x - start.x;
const dy = end.y - start.y;
const lenSq = dx * dx + dy * dy || 1;
const t = Math.max(0, Math.min(1, ((pointValue.x - start.x) * dx + (pointValue.y - start.y) * dy) / lenSq));
const x = start.x + t * dx;
const y = start.y + t * dy;
return Math.hypot(pointValue.x - x, pointValue.y - y);
};
return dimensions.find(item => {
const line = getDimensionLine(item);
return distanceToSegment(point, line.start, line.end) < 10;
}) || null;
};
const handlePointerDown = (e) => {
e.preventDefault();
const point = getCanvasPos(e);
if (dimensionMode === 'box') {
setBoxStart(point);
setBoxEnd(point);
setActiveDimensionId(null);
return;
}
if (draftPoints.length === 2) {
setIsPlacingOffset(true);
setDraftOffsetPoint(point);
return;
}
const existing = findDimensionAtPoint(point);
if (existing) {
setActiveDimensionId(existing.id);
setDimensionText(existing.label || '');
return;
}
setDraftPoints(prev => prev.length >= 2 ? [point] : [...prev, point]);
setDraftOffsetPoint(null);
setActiveDimensionId(null);
};
const handlePointerMove = (e) => {
if (dimensionMode === 'box' && boxStart) {
e.preventDefault();
setBoxEnd(getCanvasPos(e));
return;
}
if (!isPlacingOffset || draftPoints.length !== 2) return;
e.preventDefault();
setDraftOffsetPoint(getCanvasPos(e));
};
const handlePointerUp = () => {
if (dimensionMode === 'box') {
if (!boxStart || !boxEnd) return;
const rect = getRectFromPoints(boxStart, boxEnd);
if (rect.w < 8 || rect.h < 8) {
setBoxStart(null);
setBoxEnd(null);
return;
}
addBoxDimensions(rect);
setBoxStart(null);
setBoxEnd(null);
return;
}
if (!isPlacingOffset || draftPoints.length !== 2 || !draftOffsetPoint) return;
if (Math.hypot(draftPoints[1].x - draftPoints[0].x, draftPoints[1].y - draftPoints[0].y) < 8) {
setDraftPoints([]);
setDraftOffsetPoint(null);
setIsPlacingOffset(false);
return;
}
const label = dimensionText || 'Dimension';
const next = buildOffsetDimension(draftPoints[0], draftPoints[1], draftOffsetPoint, label);
setDimensions(prev => [...prev, next]);
setActiveDimensionId(next.id);
setDraftPoints([]);
setDraftOffsetPoint(null);
setIsPlacingOffset(false);
};
const updateActiveLabel = () => {
if (!activeDimensionId) return;
setDimensions(prev => prev.map(item => item.id === activeDimensionId ? { ...item, label: dimensionText || 'Dimension' } : item));
};
const removeActiveDimension = () => {
if (!activeDimensionId) return;
const selected = dimensions.find(item => item.id === activeDimensionId);
setDimensions(prev => {
if (selected?.group === 'box' && selected.groupId) {
return prev.filter(item => item.groupId !== selected.groupId);
}
return prev.filter(item => item.id !== activeDimensionId);
});
setActiveDimensionId(null);
setDimensionText('');
};
const switchDimensionMode = (mode) => {
setDimensionMode(mode);
setDraftPoints([]);
setDraftOffsetPoint(null);
setIsPlacingOffset(false);
setBoxStart(null);
setBoxEnd(null);
};
const handleApply = () => {
renderDimensionCanvas(false);
canvasRef.current.toBlob(blob => {
onApply(new File([blob], 'sign-detail-dimensions.jpg', { type: 'image/jpeg' }));
}, 'image/jpeg', 0.92);
requestAnimationFrame(() => renderDimensionCanvas(true));
};
return (
Sign Detail Dimensions
Add line dimensions or drag a box to auto-place width and height callouts.
{[
['line', 'Line'],
['box', 'Box'],
].map(([mode, label]) => (
))}
setDimensionText(e.target.value)}
placeholder='e.g. 48" W, 24" H, 8 ft'
style={{ minHeight: 32, margin: 0, width: 220 }}
/>
{dimensionMode === 'box' && (
<>
setBoxWidthText(e.target.value)}
placeholder='Width e.g. 48"'
style={{ minHeight: 32, margin: 0, width: 130 }}
/>
setBoxHeightText(e.target.value)}
placeholder='Height e.g. 24"'
style={{ minHeight: 32, margin: 0, width: 130 }}
/>
>
)}
{dimensionMode === 'box'
? 'Drag a box around the artwork/sign area to auto-add width and height.'
: (
<>
{draftPoints.length === 0 && 'Click first point.'}
{draftPoints.length === 1 && 'Click second point.'}
{draftPoints.length === 2 && 'Drag outward to place the dimension line.'}
{draftPoints.length === 0 && ' Click an existing dimension to edit it.'}
>
)}
{!loaded &&
Loading image…
}
);
}
// ─── Artwork Mockup Editor ───────────────────────────────────────────────────
const HANDLE_SIZE = 12;
function makeArtworkPoints(x, y, w, h) {
return [
{ x, y },
{ x: x + w, y },
{ x: x + w, y: y + h },
{ x, y: y + h },
];
}
function cloneArtworkState(items) {
return items.map(item => ({
...item,
points: item.points.map(point => ({ ...point })),
}));
}
function getArtworkBounds(points) {
const xs = points.map(p => p.x);
const ys = points.map(p => p.y);
const minX = Math.min(...xs);
const maxX = Math.max(...xs);
const minY = Math.min(...ys);
const maxY = Math.max(...ys);
return { x: minX, y: minY, w: maxX - minX, h: maxY - minY };
}
function getArtworkHandles(artwork) {
const labels = ['nw', 'ne', 'se', 'sw'];
return artwork.points.map((point, index) => ({ ...point, id: labels[index], index }));
}
function getRotateHandle(artwork) {
const [topLeft, topRight] = artwork.points;
const midX = (topLeft.x + topRight.x) / 2;
const midY = (topLeft.y + topRight.y) / 2;
const dx = topRight.x - topLeft.x;
const dy = topRight.y - topLeft.y;
const length = Math.hypot(dx, dy) || 1;
return {
x: midX - (dy / length) * 34,
y: midY + (dx / length) * 34,
};
}
function pointInPolygon(point, polygon) {
let inside = false;
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i, i += 1) {
const xi = polygon[i].x;
const yi = polygon[i].y;
const xj = polygon[j].x;
const yj = polygon[j].y;
const intersects = ((yi > point.y) !== (yj > point.y)) &&
(point.x < ((xj - xi) * (point.y - yi)) / (yj - yi || 1) + xi);
if (intersects) inside = !inside;
}
return inside;
}
function interpolateQuad(points, u, v) {
const [p0, p1, p2, p3] = points;
return {
x: (1 - u) * (1 - v) * p0.x + u * (1 - v) * p1.x + u * v * p2.x + (1 - u) * v * p3.x,
y: (1 - u) * (1 - v) * p0.y + u * (1 - v) * p1.y + u * v * p2.y + (1 - u) * v * p3.y,
};
}
function rotatePoints(points, degrees) {
const radians = (degrees * Math.PI) / 180;
const cos = Math.cos(radians);
const sin = Math.sin(radians);
const center = points.reduce((acc, point) => ({ x: acc.x + point.x / points.length, y: acc.y + point.y / points.length }), { x: 0, y: 0 });
return points.map(point => {
const dx = point.x - center.x;
const dy = point.y - center.y;
return {
x: center.x + dx * cos - dy * sin,
y: center.y + dx * sin + dy * cos,
};
});
}
function drawTriangleImage(ctx, img, s0, s1, s2, d0, d1, d2) {
const denom = s0.x * (s1.y - s2.y) + s1.x * (s2.y - s0.y) + s2.x * (s0.y - s1.y);
if (!denom) return;
const a = (d0.x * (s1.y - s2.y) + d1.x * (s2.y - s0.y) + d2.x * (s0.y - s1.y)) / denom;
const c = (d0.x * (s2.x - s1.x) + d1.x * (s0.x - s2.x) + d2.x * (s1.x - s0.x)) / denom;
const e = (d0.x * (s1.x * s2.y - s2.x * s1.y) + d1.x * (s2.x * s0.y - s0.x * s2.y) + d2.x * (s0.x * s1.y - s1.x * s0.y)) / denom;
const b = (d0.y * (s1.y - s2.y) + d1.y * (s2.y - s0.y) + d2.y * (s0.y - s1.y)) / denom;
const d = (d0.y * (s2.x - s1.x) + d1.y * (s0.x - s2.x) + d2.y * (s1.x - s0.x)) / denom;
const f = (d0.y * (s1.x * s2.y - s2.x * s1.y) + d1.y * (s2.x * s0.y - s0.x * s2.y) + d2.y * (s0.x * s1.y - s1.x * s0.y)) / denom;
ctx.save();
ctx.beginPath();
ctx.moveTo(d0.x, d0.y);
ctx.lineTo(d1.x, d1.y);
ctx.lineTo(d2.x, d2.y);
ctx.closePath();
ctx.clip();
ctx.transform(a, b, c, d, e, f);
ctx.drawImage(img, 0, 0);
ctx.restore();
}
function drawImageInQuad(ctx, img, points) {
const steps = 10;
for (let y = 0; y < steps; y += 1) {
for (let x = 0; x < steps; x += 1) {
const u0 = x / steps;
const u1 = (x + 1) / steps;
const v0 = y / steps;
const v1 = (y + 1) / steps;
const d00 = interpolateQuad(points, u0, v0);
const d10 = interpolateQuad(points, u1, v0);
const d11 = interpolateQuad(points, u1, v1);
const d01 = interpolateQuad(points, u0, v1);
const s00 = { x: u0 * img.naturalWidth, y: v0 * img.naturalHeight };
const s10 = { x: u1 * img.naturalWidth, y: v0 * img.naturalHeight };
const s11 = { x: u1 * img.naturalWidth, y: v1 * img.naturalHeight };
const s01 = { x: u0 * img.naturalWidth, y: v1 * img.naturalHeight };
drawTriangleImage(ctx, img, s00, s10, s11, d00, d10, d11);
drawTriangleImage(ctx, img, s00, s11, s01, d00, d11, d01);
}
}
}
function PhotoEditorModal({
sourceImage,
onApply,
onCancel,
title = 'Sign Mockup Editor',
subtitle = 'Import artwork, add dimensions, then save the edited photo.',
applyLabel = 'Apply Edited Photo',
outputName = 'sign-mockup.jpg',
downloadPrefix = 'sign-mockup',
targets = null,
}) {
const canvasRef = useRef(null);
const artworkInputRef = useRef(null);
const dragRef = useRef(null);
const layerDragRef = useRef(null);
const [baseImage, setBaseImage] = useState(null);
const [artworks, setArtworks] = useState([]);
const [dimensions, setDimensions] = useState([]);
const [history, setHistory] = useState([[]]);
const [historyIndex, setHistoryIndex] = useState(0);
const [activeTool, setActiveTool] = useState('select');
const [selectedArtworkId, setSelectedArtworkId] = useState(null);
const [activeDimensionId, setActiveDimensionId] = useState(null);
const [loaded, setLoaded] = useState(false);
const [calibrating, setCalibrating] = useState(false);
const [calibrationPoints, setCalibrationPoints] = useState([]);
const [calibrationDistance, setCalibrationDistance] = useState('');
const [sizeUnit, setSizeUnit] = useState('in');
const [artworkWidth, setArtworkWidth] = useState('');
const [artworkHeight, setArtworkHeight] = useState('');
const [lockRatio, setLockRatio] = useState(true);
const [draftPoints, setDraftPoints] = useState([]);
const [draftOffsetPoint, setDraftOffsetPoint] = useState(null);
const [isPlacingOffset, setIsPlacingOffset] = useState(false);
const [boxStart, setBoxStart] = useState(null);
const [boxEnd, setBoxEnd] = useState(null);
const [dimensionText, setDimensionText] = useState('');
const [boxWidthText, setBoxWidthText] = useState('');
const [boxHeightText, setBoxHeightText] = useState('');
const [activeTargetId, setActiveTargetId] = useState(targets?.[0]?.id || 'single');
const [targetSources, setTargetSources] = useState(() => Object.fromEntries((targets || []).map(target => [target.id, target.sourceImage || ''])));
const activeTarget = targets?.find(target => target.id === activeTargetId) || null;
const effectiveSourceImage = activeTarget ? targetSources[activeTarget.id] : sourceImage;
const effectiveApplyLabel = activeTarget?.applyLabel || applyLabel;
const effectiveOutputName = activeTarget?.outputName || outputName;
const effectiveDownloadPrefix = activeTarget?.downloadPrefix || downloadPrefix;
const selectedArtwork = artworks.find(item => item.id === selectedArtworkId) || null;
const activeDimension = dimensions.find(item => item.id === activeDimensionId) || null;
const commitArtworks = (updater) => {
setArtworks(prev => {
const next = typeof updater === 'function' ? updater(prev) : updater;
setHistory(current => {
const trimmed = current.slice(0, historyIndex + 1);
return [...trimmed, cloneArtworkState(next)].slice(-40);
});
setHistoryIndex(index => Math.min(index + 1, 39));
return next;
});
};
const setArtworksLive = (updater) => {
setArtworks(prev => typeof updater === 'function' ? updater(prev) : updater);
};
const resetEditorLayers = () => {
setArtworks([]);
setDimensions([]);
setHistory([[]]);
setHistoryIndex(0);
setSelectedArtworkId(null);
setActiveDimensionId(null);
setCalibrationPoints([]);
setCalibrating(false);
setDraftPoints([]);
setDraftOffsetPoint(null);
setIsPlacingOffset(false);
setBoxStart(null);
setBoxEnd(null);
};
useEffect(() => {
if (!targets) return;
setTargetSources(Object.fromEntries(targets.map(target => [target.id, target.sourceImage || ''])));
}, [targets]);
useEffect(() => {
resetEditorLayers();
}, [activeTargetId]);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
const initBlank = () => {
canvas.width = 800;
canvas.height = 500;
ctx.fillStyle = '#e5e5e5';
ctx.fillRect(0, 0, canvas.width, canvas.height);
setBaseImage(null);
setLoaded(true);
};
setLoaded(false);
if (!effectiveSourceImage) { initBlank(); return; }
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => {
const MAX_W = 960;
const MAX_H = 580;
const scale = Math.min(MAX_W / img.naturalWidth, MAX_H / img.naturalHeight, 1);
canvas.width = Math.round(img.naturalWidth * scale);
canvas.height = Math.round(img.naturalHeight * scale);
setBaseImage(img);
setLoaded(true);
};
img.onerror = initBlank;
img.src = effectiveSourceImage;
}, [effectiveSourceImage]);
useEffect(() => {
renderCanvas(true);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [baseImage, artworks, dimensions, selectedArtworkId, activeDimensionId, calibrationPoints, draftPoints, draftOffsetPoint, boxStart, boxEnd]);
const getCanvasPos = (event) => {
const canvas = canvasRef.current;
const rect = canvas.getBoundingClientRect();
const scaleX = canvas.width / rect.width;
const scaleY = canvas.height / rect.height;
const src = event.touches ? event.touches[0] : event;
return {
x: (src.clientX - rect.left) * scaleX,
y: (src.clientY - rect.top) * scaleY,
};
};
function getDimensionLine(item) {
if (item.lineStart && item.lineEnd) return { start: item.lineStart, end: item.lineEnd };
return { start: item.start, end: item.end };
}
function buildOffsetDimension(start, end, offsetPoint, label) {
const dx = end.x - start.x;
const dy = end.y - start.y;
const len = Math.hypot(dx, dy) || 1;
const nx = -dy / len;
const ny = dx / len;
const mid = { x: (start.x + end.x) / 2, y: (start.y + end.y) / 2 };
const offset = (offsetPoint.x - mid.x) * nx + (offsetPoint.y - mid.y) * ny;
return {
id: crypto.randomUUID(),
start,
end,
lineStart: { x: start.x + nx * offset, y: start.y + ny * offset },
lineEnd: { x: end.x + nx * offset, y: end.y + ny * offset },
label,
};
}
function drawDimension(ctx, item, selected = false) {
const color = '#000000';
const line = getDimensionLine(item);
ctx.save();
ctx.strokeStyle = color;
ctx.fillStyle = color;
const dimensionLineWidth = selected ? 3 : 2.25;
ctx.lineWidth = dimensionLineWidth;
[item.start, item.end].forEach((point, index) => {
const target = index === 0 ? line.start : line.end;
ctx.beginPath();
ctx.moveTo(point.x, point.y);
ctx.lineTo(target.x, target.y);
ctx.stroke();
});
ctx.beginPath();
ctx.moveTo(line.start.x, line.start.y);
ctx.lineTo(line.end.x, line.end.y);
ctx.stroke();
const tickLength = 18;
const tickAngle = (135 * Math.PI) / 180;
ctx.lineWidth = dimensionLineWidth * 2;
[line.start, line.end].forEach((point) => {
ctx.beginPath();
ctx.moveTo(point.x - Math.cos(tickAngle) * tickLength / 2, point.y - Math.sin(tickAngle) * tickLength / 2);
ctx.lineTo(point.x + Math.cos(tickAngle) * tickLength / 2, point.y + Math.sin(tickAngle) * tickLength / 2);
ctx.stroke();
});
ctx.lineWidth = dimensionLineWidth;
const midX = (line.start.x + line.end.x) / 2;
const midY = (line.start.y + line.end.y) / 2;
ctx.font = '700 21px Helvetica, Arial, sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
const label = item.label || 'Dimension';
const width = ctx.measureText(label).width;
const labelPaddingX = 10;
const labelHeight = 30;
const labelWidth = width + labelPaddingX * 2;
const dx = line.end.x - line.start.x;
const dy = line.end.y - line.start.y;
const lineLength = Math.hypot(dx, dy);
const isMostlyHorizontal = Math.abs(dx) >= Math.abs(dy);
const needsOutsideLabel = isMostlyHorizontal ? lineLength < labelWidth + 28 : lineLength < labelHeight + 28;
let labelX = midX;
let labelY = midY;
if (needsOutsideLabel) {
const leaderGap = 8;
if (isMostlyHorizontal) {
const leftPoint = line.start.x <= line.end.x ? line.start : line.end;
const rightPoint = line.start.x <= line.end.x ? line.end : line.start;
const placeLeft = leftPoint.x >= labelWidth + leaderGap || leftPoint.x >= ctx.canvas.width - rightPoint.x;
labelX = placeLeft ? leftPoint.x - leaderGap - labelWidth / 2 : rightPoint.x + leaderGap + labelWidth / 2;
labelY = midY;
ctx.beginPath();
ctx.moveTo(placeLeft ? leftPoint.x : rightPoint.x, midY);
ctx.lineTo(placeLeft ? labelX + labelWidth / 2 : labelX - labelWidth / 2, labelY);
ctx.stroke();
} else {
const topPoint = line.start.y <= line.end.y ? line.start : line.end;
const bottomPoint = line.start.y <= line.end.y ? line.end : line.start;
const placeTop = topPoint.y >= labelHeight + leaderGap || topPoint.y >= ctx.canvas.height - bottomPoint.y;
labelX = midX;
labelY = placeTop ? topPoint.y - leaderGap - labelHeight / 2 : bottomPoint.y + leaderGap + labelHeight / 2;
ctx.beginPath();
ctx.moveTo(midX, placeTop ? topPoint.y : bottomPoint.y);
ctx.lineTo(labelX, placeTop ? labelY + labelHeight / 2 : labelY - labelHeight / 2);
ctx.stroke();
}
}
ctx.fillStyle = '#ffffff';
ctx.fillRect(labelX - labelWidth / 2, labelY - labelHeight / 2, labelWidth, labelHeight);
ctx.fillStyle = color;
ctx.fillText(label, labelX, labelY);
ctx.restore();
}
function renderCanvas(showGuides) {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, canvas.width, canvas.height);
if (baseImage) {
ctx.drawImage(baseImage, 0, 0, canvas.width, canvas.height);
} else {
ctx.fillStyle = '#e5e5e5';
ctx.fillRect(0, 0, canvas.width, canvas.height);
}
artworks.forEach((item) => {
const alpha = item.opacity == null ? 1 : item.opacity;
ctx.save();
ctx.globalAlpha = alpha;
drawImageInQuad(ctx, item.img, item.points);
ctx.restore();
if (showGuides && item.id === selectedArtworkId) {
const rotateHandle = getRotateHandle(item);
ctx.strokeStyle = 'var(--accent)';
ctx.lineWidth = 2;
ctx.setLineDash([6, 4]);
ctx.beginPath();
item.points.forEach((point, index) => {
if (index === 0) ctx.moveTo(point.x, point.y);
else ctx.lineTo(point.x, point.y);
});
ctx.closePath();
ctx.stroke();
ctx.setLineDash([]);
ctx.beginPath();
const topMid = { x: (item.points[0].x + item.points[1].x) / 2, y: (item.points[0].y + item.points[1].y) / 2 };
ctx.moveTo(topMid.x, topMid.y);
ctx.lineTo(rotateHandle.x, rotateHandle.y);
ctx.stroke();
getArtworkHandles(item).forEach(({ x, y }) => {
ctx.fillStyle = '#ffffff';
ctx.strokeStyle = 'var(--accent)';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.rect(x - HANDLE_SIZE / 2, y - HANDLE_SIZE / 2, HANDLE_SIZE, HANDLE_SIZE);
ctx.fill();
ctx.stroke();
});
ctx.fillStyle = '#1a1a1a';
ctx.strokeStyle = 'var(--accent)';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(rotateHandle.x, rotateHandle.y, HANDLE_SIZE / 1.5, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
ctx.fillStyle = 'var(--accent)';
ctx.font = '700 10px Helvetica, Arial, sans-serif';
ctx.fillText('↻', rotateHandle.x - 4, rotateHandle.y + 4);
}
});
dimensions.forEach(item => drawDimension(ctx, item, item.id === activeDimensionId));
if (showGuides && draftPoints.length > 0) {
ctx.fillStyle = '#000000';
draftPoints.forEach((point) => {
ctx.beginPath();
ctx.arc(point.x, point.y, 5, 0, Math.PI * 2);
ctx.fill();
});
}
if (showGuides && draftPoints.length === 2 && draftOffsetPoint) {
drawDimension(ctx, buildOffsetDimension(draftPoints[0], draftPoints[1], draftOffsetPoint, dimensionText || 'Dimension'), true);
}
if (showGuides && boxStart && boxEnd) {
const rect = getRectFromPoints(boxStart, boxEnd);
ctx.save();
ctx.strokeStyle = '#000000';
ctx.lineWidth = 2;
ctx.setLineDash([6, 4]);
ctx.strokeRect(rect.x, rect.y, rect.w, rect.h);
ctx.setLineDash([]);
ctx.restore();
}
if (showGuides && calibrationPoints.length > 0) {
ctx.fillStyle = 'var(--success)';
calibrationPoints.forEach((point) => {
ctx.beginPath();
ctx.arc(point.x, point.y, 5, 0, Math.PI * 2);
ctx.fill();
});
if (calibrationPoints.length === 2) {
ctx.strokeStyle = 'var(--success)';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(calibrationPoints[0].x, calibrationPoints[0].y);
ctx.lineTo(calibrationPoints[1].x, calibrationPoints[1].y);
ctx.stroke();
}
}
}
const hitHandle = (point, item = selectedArtwork) => {
if (!item) return null;
return getArtworkHandles(item).find(handle =>
Math.abs(point.x - handle.x) <= HANDLE_SIZE && Math.abs(point.y - handle.y) <= HANDLE_SIZE
) || null;
};
const hitRotateHandle = (point, item = selectedArtwork) => {
if (!item) return false;
const handle = getRotateHandle(item);
return Math.hypot(point.x - handle.x, point.y - handle.y) <= HANDLE_SIZE * 1.4;
};
const findArtworkAtPoint = (point) => {
for (let i = artworks.length - 1; i >= 0; i -= 1) {
if (pointInPolygon(point, artworks[i].points)) return artworks[i];
}
return null;
};
const pxPerInch = (() => {
if (calibrationPoints.length !== 2 || !Number(calibrationDistance)) return null;
const [a, b] = calibrationPoints;
const px = Math.hypot(b.x - a.x, b.y - a.y);
return px / Number(calibrationDistance);
})();
function getRectFromPoints(a, b) {
const x = Math.min(a.x, b.x);
const y = Math.min(a.y, b.y);
return { x, y, w: Math.abs(b.x - a.x), h: Math.abs(b.y - a.y) };
}
function addBoxDimensions(rect) {
const offset = 28;
const groupId = crypto.randomUUID();
const widthDim = {
...buildOffsetDimension(
{ x: rect.x, y: rect.y },
{ x: rect.x + rect.w, y: rect.y },
{ x: rect.x + rect.w / 2, y: rect.y - offset },
boxWidthText || dimensionText || 'Width'
),
group: 'box',
groupId,
};
const heightDim = {
...buildOffsetDimension(
{ x: rect.x, y: rect.y },
{ x: rect.x, y: rect.y + rect.h },
{ x: rect.x - offset, y: rect.y + rect.h / 2 },
boxHeightText || dimensionText || 'Height'
),
group: 'box',
groupId,
};
setDimensions(prev => [...prev, widthDim, heightDim]);
setActiveDimensionId(widthDim.id);
setSelectedArtworkId(null);
}
const autoDimensionSelectedArtwork = () => {
if (!selectedArtwork) return;
const bounds = getArtworkBounds(selectedArtwork.points);
if (bounds.w < 8 || bounds.h < 8) return;
addBoxDimensions(bounds);
};
const findDimensionAtPoint = (point) => {
const distanceToSegment = (pointValue, start, end) => {
const dx = end.x - start.x;
const dy = end.y - start.y;
const lenSq = dx * dx + dy * dy || 1;
const t = Math.max(0, Math.min(1, ((pointValue.x - start.x) * dx + (pointValue.y - start.y) * dy) / lenSq));
const x = start.x + t * dx;
const y = start.y + t * dy;
return Math.hypot(pointValue.x - x, pointValue.y - y);
};
return dimensions.find(item => {
const line = getDimensionLine(item);
return distanceToSegment(point, line.start, line.end) < 12;
}) || null;
};
const updateActiveDimensionLabel = () => {
if (!activeDimensionId) return;
setDimensions(prev => prev.map(item => item.id === activeDimensionId ? { ...item, label: dimensionText || 'Dimension' } : item));
};
const removeActiveDimension = () => {
if (!activeDimensionId) return;
const selected = dimensions.find(item => item.id === activeDimensionId);
setDimensions(prev => {
if (selected?.group === 'box' && selected.groupId) return prev.filter(item => item.groupId !== selected.groupId);
return prev.filter(item => item.id !== activeDimensionId);
});
setActiveDimensionId(null);
setDimensionText('');
};
const resetDimensionDraft = () => {
setDraftPoints([]);
setDraftOffsetPoint(null);
setIsPlacingOffset(false);
setBoxStart(null);
setBoxEnd(null);
};
const autoDetectBoxDimensions = () => {
if (!baseImage) return;
const canvas = canvasRef.current;
if (!canvas) return;
const detectCanvas = document.createElement('canvas');
const maxDetectW = 300;
const scale = Math.min(maxDetectW / canvas.width, 1);
detectCanvas.width = Math.max(1, Math.round(canvas.width * scale));
detectCanvas.height = Math.max(1, Math.round(canvas.height * scale));
const detectCtx = detectCanvas.getContext('2d', { willReadFrequently: true });
detectCtx.drawImage(baseImage, 0, 0, detectCanvas.width, detectCanvas.height);
const { width, height } = detectCanvas;
const { data } = detectCtx.getImageData(0, 0, width, height);
const columnCounts = new Uint16Array(width);
const rowCounts = new Uint16Array(height);
const marginX = Math.round(width * 0.01);
const marginY = Math.round(height * 0.01);
let nonWhiteCount = 0;
for (let y = marginY; y < height - marginY; y++) {
for (let x = marginX; x < width - marginX; x++) {
const p = (y * width + x) * 4;
const isWhite = data[p] >= 245 && data[p + 1] >= 245 && data[p + 2] >= 245;
if (data[p + 3] > 24 && !isWhite) {
columnCounts[x] += 1;
rowCounts[y] += 1;
nonWhiteCount += 1;
}
}
}
const minColumnHits = Math.max(2, Math.round(height * 0.006));
const minRowHits = Math.max(2, Math.round(width * 0.006));
let minX = -1;
let maxX = -1;
let minY = -1;
let maxY = -1;
for (let x = marginX; x < width - marginX; x++) if (columnCounts[x] >= minColumnHits) { minX = x; break; }
for (let x = width - marginX - 1; x >= marginX; x--) if (columnCounts[x] >= minColumnHits) { maxX = x; break; }
for (let y = marginY; y < height - marginY; y++) if (rowCounts[y] >= minRowHits) { minY = y; break; }
for (let y = height - marginY - 1; y >= marginY; y--) if (rowCounts[y] >= minRowHits) { maxY = y; break; }
const boundsW = maxX - minX;
const boundsH = maxY - minY;
const nonWhiteRatio = nonWhiteCount / ((width - marginX * 2) * (height - marginY * 2));
if (minX < 0 || minY < 0 || boundsW < width * 0.03 || boundsH < height * 0.03 || nonWhiteRatio > 0.65) {
window.alert('Could not detect a clear non-white object. Drag a box manually.');
return;
}
const padding = 4;
const rectX = Math.max(0, minX / scale - padding);
const rectY = Math.max(0, minY / scale - padding);
addBoxDimensions({
x: rectX,
y: rectY,
w: Math.min(canvas.width, maxX / scale + padding) - rectX,
h: Math.min(canvas.height, maxY / scale + padding) - rectY,
});
};
const handleArtworkFiles = (fileList) => {
const files = Array.from(fileList || []).filter(isPhotoFile);
if (!files.length) {
alert('Please upload image artwork.');
return;
}
files.forEach((file, fileIndex) => {
const url = URL.createObjectURL(file);
const img = new Image();
img.onload = () => {
URL.revokeObjectURL(url);
const canvas = canvasRef.current;
const maxW = canvas.width * 0.35;
const maxH = canvas.height * 0.35;
const scale = Math.min(maxW / img.naturalWidth, maxH / img.naturalHeight, 1);
const w = Math.max(40, img.naturalWidth * scale);
const h = Math.max(40, img.naturalHeight * scale);
const id = crypto.randomUUID();
const offset = (artworks.length + fileIndex) * 18;
const x = (canvas.width - w) / 2 + offset;
const y = (canvas.height - h) / 2 + offset;
const nextArtwork = {
id,
name: file.name,
img,
points: makeArtworkPoints(x, y, w, h),
ratio: img.naturalWidth / img.naturalHeight,
opacity: 1,
};
commitArtworks(prev => [...prev, nextArtwork]);
setSelectedArtworkId(id);
setArtworkWidth('');
setArtworkHeight('');
};
img.onerror = () => {
URL.revokeObjectURL(url);
alert('Could not load that artwork file.');
};
img.src = url;
});
};
const applySpecifiedSize = () => {
if (!selectedArtwork) return;
const widthValue = Number(artworkWidth);
const heightValue = Number(artworkHeight);
if (!widthValue && !heightValue) return;
if (sizeUnit === 'in' && !pxPerInch) {
alert('Calibrate the photo first, or switch the size unit to pixels.');
return;
}
const multiplier = sizeUnit === 'in' ? pxPerInch : 1;
const bounds = getArtworkBounds(selectedArtwork.points);
let nextW = widthValue ? widthValue * multiplier : bounds.w;
let nextH = heightValue ? heightValue * multiplier : bounds.h;
if (lockRatio && widthValue && !heightValue) nextH = nextW / selectedArtwork.ratio;
if (lockRatio && heightValue && !widthValue) nextW = nextH * selectedArtwork.ratio;
if (lockRatio && widthValue && heightValue) nextH = nextW / selectedArtwork.ratio;
const centerX = bounds.x + bounds.w / 2;
const centerY = bounds.y + bounds.h / 2;
commitArtworks(prev => prev.map(item => item.id === selectedArtwork.id
? { ...item, points: makeArtworkPoints(centerX - nextW / 2, centerY - nextH / 2, Math.max(10, nextW), Math.max(10, nextH)) }
: item
));
};
const removeSelectedArtwork = () => {
if (!selectedArtworkId) return;
commitArtworks(prev => {
const next = prev.filter(item => item.id !== selectedArtworkId);
setSelectedArtworkId(next[next.length - 1]?.id || null);
return next;
});
};
const duplicateSelectedArtwork = () => {
if (!selectedArtwork) return;
const id = crypto.randomUUID();
const copy = {
...selectedArtwork,
id,
name: `${selectedArtwork.name || 'Artwork'} copy`,
points: selectedArtwork.points.map(point => ({ x: point.x + 18, y: point.y + 18 })),
};
commitArtworks(prev => [...prev, copy]);
setSelectedArtworkId(id);
};
const reorderLayer = (dragId, targetId) => {
if (!dragId || !targetId || dragId === targetId) return;
commitArtworks(prev => {
const next = [...prev];
const from = next.findIndex(item => item.id === dragId);
const to = next.findIndex(item => item.id === targetId);
if (from < 0 || to < 0) return prev;
const [moved] = next.splice(from, 1);
next.splice(to, 0, moved);
return next;
});
};
const updateSelectedArtwork = (changes) => {
if (!selectedArtworkId) return;
commitArtworks(prev => prev.map(item => item.id === selectedArtworkId ? { ...item, ...changes } : item));
};
const nudgeSelectedArtwork = (dx, dy) => {
if (!selectedArtworkId) return;
commitArtworks(prev => prev.map(item => item.id === selectedArtworkId
? { ...item, points: item.points.map(point => ({ x: point.x + dx, y: point.y + dy })) }
: item
));
};
const undo = () => {
if (historyIndex <= 0) return;
const nextIndex = historyIndex - 1;
const next = cloneArtworkState(history[nextIndex] || []);
setHistoryIndex(nextIndex);
setArtworks(next);
setSelectedArtworkId(current => next.some(item => item.id === current) ? current : next[next.length - 1]?.id || null);
};
const redo = () => {
if (historyIndex >= history.length - 1) return;
const nextIndex = historyIndex + 1;
const next = cloneArtworkState(history[nextIndex] || []);
setHistoryIndex(nextIndex);
setArtworks(next);
setSelectedArtworkId(current => next.some(item => item.id === current) ? current : next[next.length - 1]?.id || null);
};
const handlePointerDown = (e) => {
e.preventDefault();
const point = getCanvasPos(e);
if (activeTool === 'box') {
setBoxStart(point);
setBoxEnd(point);
setActiveDimensionId(null);
setSelectedArtworkId(null);
return;
}
if (activeTool === 'dimension') {
if (draftPoints.length === 2) {
setIsPlacingOffset(true);
setDraftOffsetPoint(point);
return;
}
const existingDimension = findDimensionAtPoint(point);
if (existingDimension) {
setActiveDimensionId(existingDimension.id);
setSelectedArtworkId(null);
setDimensionText(existingDimension.label || '');
return;
}
setDraftPoints(prev => prev.length >= 2 ? [point] : [...prev, point]);
setDraftOffsetPoint(null);
setActiveDimensionId(null);
setSelectedArtworkId(null);
return;
}
if (calibrating) {
setCalibrationPoints(prev => {
const next = prev.length >= 2 ? [point] : [...prev, point];
if (next.length === 2) setCalibrating(false);
return next;
});
return;
}
const existingDimension = findDimensionAtPoint(point);
if (existingDimension && !findArtworkAtPoint(point)) {
setActiveDimensionId(existingDimension.id);
setSelectedArtworkId(null);
setDimensionText(existingDimension.label || '');
return;
}
const targetArtwork = selectedArtwork && (hitHandle(point, selectedArtwork) || pointInPolygon(point, selectedArtwork.points))
? selectedArtwork
: findArtworkAtPoint(point);
if (!targetArtwork) {
setSelectedArtworkId(null);
setActiveDimensionId(null);
return;
}
setSelectedArtworkId(targetArtwork.id);
setActiveDimensionId(null);
const handle = hitHandle(point, targetArtwork);
if (hitRotateHandle(point, targetArtwork)) {
const center = targetArtwork.points.reduce((acc, p) => ({ x: acc.x + p.x / targetArtwork.points.length, y: acc.y + p.y / targetArtwork.points.length }), { x: 0, y: 0 });
dragRef.current = {
type: 'rotate',
startAngle: Math.atan2(point.y - center.y, point.x - center.x),
center,
initial: { ...targetArtwork, points: targetArtwork.points.map(p => ({ ...p })) },
};
} else if (handle) {
dragRef.current = { type: 'resize', handle, start: point, initial: { ...targetArtwork, points: targetArtwork.points.map(p => ({ ...p })) } };
} else {
dragRef.current = { type: 'move', start: point, initial: { ...targetArtwork, points: targetArtwork.points.map(p => ({ ...p })) } };
}
};
const handlePointerMove = (e) => {
if (activeTool === 'box' && boxStart) {
e.preventDefault();
setBoxEnd(getCanvasPos(e));
return;
}
if (activeTool === 'dimension' && isPlacingOffset && draftPoints.length === 2) {
e.preventDefault();
setDraftOffsetPoint(getCanvasPos(e));
return;
}
if (!dragRef.current) return;
e.preventDefault();
const point = getCanvasPos(e);
const drag = dragRef.current;
if (drag.type === 'move') {
const dx = point.x - drag.start.x;
const dy = point.y - drag.start.y;
const points = drag.initial.points.map(p => ({ x: p.x + dx, y: p.y + dy }));
setArtworksLive(prev => prev.map(item => item.id === drag.initial.id ? { ...item, points } : item));
return;
}
if (drag.type === 'rotate') {
const currentAngle = Math.atan2(point.y - drag.center.y, point.x - drag.center.x);
const degrees = ((currentAngle - drag.startAngle) * 180) / Math.PI;
const points = rotatePoints(drag.initial.points, degrees);
setArtworksLive(prev => prev.map(item => item.id === drag.initial.id ? { ...item, points } : item));
return;
}
const minSize = 16;
const handleIndex = drag.handle.index;
if (!lockRatio) {
const points = drag.initial.points.map((p, index) => index === handleIndex ? point : { ...p });
setArtworksLive(prev => prev.map(item => item.id === drag.initial.id ? { ...item, points } : item));
return;
}
const oppositeIndex = (handleIndex + 2) % 4;
const anchor = drag.initial.points[oppositeIndex];
const dx = point.x - anchor.x;
const dy = point.y - anchor.y;
let w = Math.max(minSize, Math.abs(dx));
let h = w / drag.initial.ratio;
if (Math.abs(dy) > h) {
h = Math.max(minSize, Math.abs(dy));
w = h * drag.initial.ratio;
}
const x = dx >= 0 ? anchor.x : anchor.x - w;
const y = dy >= 0 ? anchor.y : anchor.y - h;
const points = makeArtworkPoints(x, y, w, h);
setArtworksLive(prev => prev.map(item => item.id === drag.initial.id ? { ...item, points } : item));
};
const handlePointerUp = () => {
if (activeTool === 'box') {
if (!boxStart || !boxEnd) return;
const rect = getRectFromPoints(boxStart, boxEnd);
if (rect.w >= 8 && rect.h >= 8) addBoxDimensions(rect);
setBoxStart(null);
setBoxEnd(null);
return;
}
if (activeTool === 'dimension') {
if (!isPlacingOffset || draftPoints.length !== 2 || !draftOffsetPoint) return;
if (Math.hypot(draftPoints[1].x - draftPoints[0].x, draftPoints[1].y - draftPoints[0].y) >= 8) {
const next = buildOffsetDimension(draftPoints[0], draftPoints[1], draftOffsetPoint, dimensionText || 'Dimension');
setDimensions(prev => [...prev, next]);
setActiveDimensionId(next.id);
}
setDraftPoints([]);
setDraftOffsetPoint(null);
setIsPlacingOffset(false);
return;
}
if (dragRef.current) {
const draggedId = dragRef.current.initial.id;
setArtworks(prev => {
setHistory(current => {
const trimmed = current.slice(0, historyIndex + 1);
return [...trimmed, cloneArtworkState(prev)].slice(-40);
});
setHistoryIndex(index => Math.min(index + 1, 39));
setSelectedArtworkId(draggedId);
return prev;
});
}
dragRef.current = null;
};
const handleArtworkDrop = (e) => {
e.preventDefault();
handleArtworkFiles(e.dataTransfer.files);
};
const handleApply = () => {
renderCanvas(false);
canvasRef.current.toBlob(blob => {
if (!blob) return;
const file = new File([blob], effectiveOutputName, { type: 'image/jpeg' });
if (activeTarget) {
setTargetSources(prev => ({ ...prev, [activeTarget.id]: URL.createObjectURL(blob) }));
onApply(file, activeTarget.id);
} else {
onApply(file);
}
}, 'image/jpeg', 0.92);
requestAnimationFrame(() => renderCanvas(true));
};
const saveAsJpg = () => {
renderCanvas(false);
canvasRef.current.toBlob(blob => {
if (!blob) return;
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${effectiveDownloadPrefix}-${new Date().toISOString().slice(0, 10)}.jpg`;
a.click();
URL.revokeObjectURL(url);
requestAnimationFrame(() => renderCanvas(true));
}, 'image/jpeg', 0.92);
};
useEffect(() => {
const handleKeyDown = (event) => {
if (!selectedArtworkId) return;
const target = event.target;
if (target && ['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName)) return;
const step = event.shiftKey ? 10 : 1;
if (event.key === 'ArrowUp') { event.preventDefault(); nudgeSelectedArtwork(0, -step); }
if (event.key === 'ArrowDown') { event.preventDefault(); nudgeSelectedArtwork(0, step); }
if (event.key === 'ArrowLeft') { event.preventDefault(); nudgeSelectedArtwork(-step, 0); }
if (event.key === 'ArrowRight') { event.preventDefault(); nudgeSelectedArtwork(step, 0); }
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'z') {
event.preventDefault();
if (event.shiftKey) redo();
else undo();
}
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'y') {
event.preventDefault();
redo();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedArtworkId, historyIndex, history]);
return (
{/* Header */}
{targets && (
{targets.map(target => (
))}
Apply saves the current tab, then you can switch tabs without closing.
)}
{/* Toolbar */}
{[
['select', 'Select'],
['dimension', 'Line Dim'],
['box', 'Box Dim'],
].map(([tool, label]) => (
))}
{ if (e.target.files.length) handleArtworkFiles(e.target.files); e.target.value = ''; }}
/>
setDimensionText(e.target.value)}
placeholder='Dimension text'
style={{ minHeight: 32, margin: 0, width: 145 }}
/>
{activeTool === 'box' && (
<>
setBoxWidthText(e.target.value)}
placeholder='Width'
style={{ minHeight: 32, margin: 0, width: 90 }}
/>
setBoxHeightText(e.target.value)}
placeholder='Height'
style={{ minHeight: 32, margin: 0, width: 90 }}
/>
>
)}
setCalibrationDistance(e.target.value)}
style={{ width: 110, minHeight: 32, margin: 0 }}
/>
inches
setArtworkWidth(e.target.value)}
style={{ width: 100, minHeight: 32, margin: 0 }}
/>
setArtworkHeight(e.target.value)}
style={{ width: 100, minHeight: 32, margin: 0 }}
/>
{pxPerInch ? `${pxPerInch.toFixed(1)} px/in` : calibrating ? 'Click two points on a known-size item' : 'Drop artwork here, drag layers, lock ratio for normal resize, unlock for free transform'}
Layers
{dimensions.length > 0 && (
<>
Dimensions
{dimensions.map((item, index) => (
))}
>
)}
Artwork
{artworks.length === 0 ? (
Import or drop artwork to create layers.
) : (
{[...artworks].reverse().map((item) => {
const displayIndex = artworks.findIndex(layer => layer.id === item.id) + 1;
return (
);
})}
)}
Drag artwork layers up/down to change stacking. Use arrow keys to nudge selected artwork.
{/* Canvas area */}
e.preventDefault()}
onDrop={handleArtworkDrop}
style={{ overflow: 'auto', background: 'var(--card-bg-2)', display: 'flex', alignItems: 'flex-start', justifyContent: 'center', position: 'relative', minHeight: 420 }}
>
{!loaded && (
Loading image…
)}
{/* Footer */}
);
}