From a49d0b0c6c584cd09f7118d5121cbabff15e849f Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sun, 17 May 2026 19:34:11 -0400 Subject: [PATCH] Custom Art mode Phase A: editor + state + save/load Adds a new template mode for free-form digital canvases (wallpapers, banners, social images, posters). One canvas, layers stack on top. Phase A is the editor foundation; publish + browser come next. State: - New state.customArt = { width, height, background, preset } defaulting to 1920x1080 white - CUSTOM_ART_PRESETS (10 entries: 16:9, 9:16, QHD, 4K, 1:1, 4:5, 3:1, Letter portrait/landscape, plus Custom) - customArtCanvasInches() converts pixel dims to internal inches at 96dpi so the existing inch-based layer math keeps working unchanged Mode integration: - normalizeTemplateMode recognizes 'customart' / 'custom-art' and payload.customArt fallback - sheetW() / sheetH() return the canvas in inches when in customart mode - targetBounds('canvas') returns the full canvas - applyTargetMode, updateLayerTargetOptions, templateLayerTargetsForMode all support the 'canvas' target - New body.template-customart class hides cover/disc/jewel-specific fieldsets, bleed, trim, fold, and slot UI Editor: - New "Custom art" layout tab in the sidebar mode switcher - New #customArtFieldset with preset dropdown, width/height inputs (min 100, max 8192), and background color picker - Selecting a preset snaps width/height; manual edits flip preset to "Custom" automatically - Canvas background applied to .sheet inline; reset when leaving the mode - Layers panel shows a single Canvas region for customart with the same Add Image / Add Text / Add Color buttons Persistence: - customArtPayload() function mirrors discDesignPayload / coverTemplate - exportPayload() now includes a customArt blob alongside the other modes - downloadProject routes to customArtPayload with filename custom_art_.json - loadProjectFromText has a customart branch that restores state.customArt plus canvas-targeted layers without clobbering other modes' state Co-Authored-By: Claude Sonnet 4.6 --- src/index.html | 183 +++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 177 insertions(+), 6 deletions(-) diff --git a/src/index.html b/src/index.html index 0009262..032c9d2 100644 --- a/src/index.html +++ b/src/index.html @@ -823,7 +823,27 @@ /* Export toggles: fold/safe/trim/bleed/crops only apply to case covers */ body.template-disc .cover-toggle, - body.template-jewel .cover-toggle { + body.template-jewel .cover-toggle, + body.template-customart .cover-toggle { + display: none !important; + } + + /* Custom Art mode: hide all the slot/region UI and decorations, show only the canvas + layers */ + body:not(.template-customart) #customArtFieldset { + display: none !important; + } + body.template-customart #coverSizeFieldset, + body.template-customart #coverArtworkFieldset, + body.template-customart #discArtworkFieldset, + body.template-customart #jewelArtworkFieldset, + body.template-customart .coverOnly, + body.template-customart .diskLayer, + body.template-customart .jewelLayer, + body.template-customart .bleedFill, + body.template-customart .trimArea, + body.template-customart .foldLine, + body.template-customart .discSpineStrip, + body.template-customart .jewelSpineGuide { display: none !important; } @@ -1840,6 +1860,7 @@
+
@@ -1982,6 +2003,25 @@ +
+ Canvas + + +
+
+ + +
+
+ + +
+
+ + +

100–8192 px. Add layers (image / text / color) using the Layers panel below.

+
+
Artwork @@ -2741,6 +2781,9 @@ { key: 'jewel-front', label: 'Jewel front insert' }, { key: 'jewel-tray', label: 'Jewel tray insert' } ]; + if (state.templateMode === 'customart') return [ + { key: 'canvas', label: 'Canvas' } + ]; return TEMPLATE_LAYER_TARGETS; } @@ -2771,6 +2814,28 @@ ] }; + const CUSTOM_ART_MIN = 100; + const CUSTOM_ART_MAX = 8192; + const CUSTOM_ART_PRESETS = [ + { key: '1920x1080', label: '1920 × 1080 · Desktop wallpaper (16:9)', w: 1920, h: 1080 }, + { key: '1080x1920', label: '1080 × 1920 · Phone wallpaper (9:16)', w: 1080, h: 1920 }, + { key: '2560x1440', label: '2560 × 1440 · QHD (16:9)', w: 2560, h: 1440 }, + { key: '3840x2160', label: '3840 × 2160 · 4K UHD (16:9)', w: 3840, h: 2160 }, + { key: '1080x1080', label: '1080 × 1080 · Square (1:1)', w: 1080, h: 1080 }, + { key: '1080x1350', label: '1080 × 1350 · Portrait (4:5)', w: 1080, h: 1350 }, + { key: '1500x500', label: '1500 × 500 · Banner (3:1)', w: 1500, h: 500 }, + { key: '850x1100', label: '850 × 1100 · US Letter portrait', w: 850, h: 1100 }, + { key: '1100x850', label: '1100 × 850 · US Letter landscape', w: 1100, h: 850 }, + { key: 'custom', label: 'Custom…', w: 0, h: 0 } + ]; + + function customArtCanvasInches(ca = state.customArt) { + return { + wIn: Math.max(CUSTOM_ART_MIN, Math.min(CUSTOM_ART_MAX, Number(ca?.width) || 1920)) / 96, + hIn: Math.max(CUSTOM_ART_MIN, Math.min(CUSTOM_ART_MAX, Number(ca?.height) || 1080)) / 96 + }; + } + const discSlotNames = [ { key: 'top', label: 'Disc 1' }, { key: 'bottom', label: 'Disc 2' } @@ -2857,6 +2922,7 @@ bottom: { title: '', imdbId: '', category: '', language: 'en' } }, discDesign: { name: '', src: '', fit: 'cover', zoom: 1, x: 0, y: 0, rotate: 0 }, + customArt: { width: 1920, height: 1080, background: '#ffffff', preset: '1920x1080' }, discSpineLabels: { top: '', bottom: '' }, jewelSpineLabels: { left: '', right: '' }, jewelInserts: { @@ -3158,7 +3224,9 @@ if (raw === 'disc-sheet') return 'disc'; if (raw === 'jewel-inserts') return 'jewel'; if (raw === 'disc-design') return 'disc-design'; + if (raw === 'customart' || raw === 'custom-art') return 'customart'; if (raw === 'cover' || raw === 'disc' || raw === 'jewel') return raw; + if (payload.customArt) return 'customart'; if (payload.discTemplate || payload.discMeta) return 'disc'; if (payload.discDesign && !payload.discTemplate) return 'disc-design'; if (payload.coverTemplate || payload.slots || payload.combined) return 'cover'; @@ -3601,12 +3669,14 @@ function sheetW() { if (state.templateMode === 'disc') return discTemplate.sheetW; if (state.templateMode === 'jewel') return jewelTemplate.sheetW; + if (state.templateMode === 'customart') return customArtCanvasInches().wIn; return totalTrimW() + state.bleed * 2; } function sheetH() { if (state.templateMode === 'disc') return discTemplate.sheetH; if (state.templateMode === 'jewel') return jewelTemplate.sheetH; + if (state.templateMode === 'customart') return customArtCanvasInches().hIn; return state.trimH + state.bleed * 2; } @@ -3638,8 +3708,9 @@ } function syncInputs() { - for (const mode of ['cover', 'disc']) { + for (const mode of ['cover', 'disc', 'customart']) { const button = $(`mode-${mode}`); + if (!button) continue; const active = state.templateMode === mode; button.classList.toggle('active', active); button.setAttribute('aria-selected', active ? 'true' : 'false'); @@ -3688,6 +3759,17 @@ const stb = $('spineTextBottom'); if (stb) stb.value = state.discSpineLabels?.bottom || ''; const jsl = $('jewelSpineTextLeft'); if (jsl) jsl.value = state.jewelSpineLabels?.left || ''; const jsr = $('jewelSpineTextRight'); if (jsr) jsr.value = state.jewelSpineLabels?.right || ''; + const caPresetEl = $('customArtPreset'); + if (caPresetEl) { + if (!caPresetEl.options.length) { + caPresetEl.innerHTML = CUSTOM_ART_PRESETS.map(p => + ``).join(''); + } + caPresetEl.value = state.customArt?.preset || 'custom'; + } + const caW = $('customArtWidth'); if (caW) caW.value = state.customArt?.width || 1920; + const caH = $('customArtHeight'); if (caH) caH.value = state.customArt?.height || 1080; + const caBg = $('customArtBg'); if (caBg) caBg.value = state.customArt?.background || '#ffffff'; syncPublishReadout(); syncModeTabsUI(); } @@ -3705,6 +3787,7 @@ document.body.classList.toggle('template-cover', state.templateMode === 'cover'); document.body.classList.toggle('template-disc', state.templateMode === 'disc'); document.body.classList.toggle('template-jewel', state.templateMode === 'jewel'); + document.body.classList.toggle('template-customart', state.templateMode === 'customart'); document.body.classList.toggle('hide-guides', !state.showGuides); document.body.classList.toggle('hide-bleed', !state.showBleed); document.body.classList.toggle('hide-crops', !state.showCrops); @@ -3739,7 +3822,19 @@ const pageH = state.templateMode === 'cover' ? 8.5 : sheetH(); $('dynamicPageStyle').textContent = `@page { size: ${fmt(pageW)}in ${fmt(pageH)}in; margin: 0; }`; - if (state.templateMode === 'disc') { + // Reset inline sheet background; customart branch sets it below. + $('sheet').style.background = ''; + + if (state.templateMode === 'customart') { + const ca = state.customArt || {}; + const w = Math.max(CUSTOM_ART_MIN, Math.min(CUSTOM_ART_MAX, Number(ca.width) || 1920)); + const h = Math.max(CUSTOM_ART_MIN, Math.min(CUSTOM_ART_MAX, Number(ca.height) || 1080)); + $('projectTitle').textContent = 'Custom art canvas'; + document.querySelector('.printHelp').textContent = `${w} × ${h} px canvas. Export as JPEG for sharing.`; + $('trimReadout').textContent = `${w} × ${h} px`; + $('sheetReadout').textContent = `(${fmt(w/96)} × ${fmt(h/96)} in)`; + $('sheet').style.background = ca.background || '#ffffff'; + } else if (state.templateMode === 'disc') { $('projectTitle').textContent = 'Disc label preview'; document.querySelector('.printHelp').textContent = 'Avery 8960 / 8944 layout: two full-face disc labels on Letter paper.'; $('trimReadout').textContent = `Disc: ${fmt(discTemplate.discD)} in diameter`; @@ -4615,6 +4710,10 @@ switch (target) { case 'designer-disc': return { x: 0, y: 0, w: discTemplate.discD, h: discTemplate.discD }; + case 'canvas': { + const { wIn, hIn } = customArtCanvasInches(); + return { x: 0, y: 0, w: wIn, h: hIn }; + } case 'cover-trim': return { x: state.bleed, y: state.bleed, w: totalTrimW(), h: state.trimH }; case 'back': @@ -4642,7 +4741,9 @@ syncModeTabsUI(); return; } - if (target.startsWith('disc')) { + if (target === 'canvas') { + state.templateMode = 'customart'; + } else if (target.startsWith('disc')) { state.templateMode = 'disc'; } else if (target.startsWith('jewel')) { state.templateMode = 'jewel'; @@ -5968,6 +6069,18 @@ }; } + function customArtPayload() { + return { + version: SCHEMA_VERSION, + templateMode: 'customart', + templateType: 'customart', + meta: state.meta, + ui: { zoom: state.zoom, showGuides: state.showGuides }, + customArt: { ...state.customArt }, + layers: nonGeneratedLayers().filter(l => l.target === 'canvas') + }; + } + function exportPayload() { const templateType = templateTypeForMode(); return { @@ -5990,6 +6103,7 @@ ...(state.templateMode === 'disc-design' ? { discDesign: state.discDesign } : {}), discTemplate: { templateType: templateTypeForMode('disc'), config: discTemplate, discs: state.discs, spineLabels: state.discSpineLabels, meta: state.discMeta }, jewelTemplate: { templateType: templateTypeForMode('jewel'), preset: 'jewel8693', config: jewelTemplate, inserts: state.jewelInserts, spineLabels: state.jewelSpineLabels }, + customArt: { ...state.customArt }, layers: nonGeneratedLayers(), community: { relays: state.community.relays, query: state.community.query, kind: state.community.kind } }; @@ -6486,6 +6600,9 @@ triggerDownload(discSheetPayload(), discProjectFilename()); } else if (state.templateMode === 'jewel') { triggerDownload(jewelTemplatePayload(), projectFilename('cd_jewel_case', 'jewel8693')); + } else if (state.templateMode === 'customart') { + const ca = state.customArt || { width: 1920, height: 1080 }; + triggerDownload(customArtPayload(), projectFilename('custom_art', `${ca.width}x${ca.height}`)); } else { triggerDownload(coverTemplatePayload(), projectFilename('case_cover', state.preset)); } @@ -6664,6 +6781,23 @@ return; } + if (mode === 'customart' || parsed.customArt) { + Object.assign(state, { + templateMode: 'customart', + meta: { ...state.meta, ...(parsed.meta || {}) }, + zoom: parsed.ui?.zoom ?? state.zoom, + showGuides: parsed.ui?.showGuides ?? state.showGuides, + customArt: { ...state.customArt, ...(parsed.customArt || {}) }, + layers: [ + ...state.layers.filter(l => l.target !== 'canvas'), + ...(Array.isArray(parsed.layers) ? parsed.layers : []) + ], + selectedLayerId: '' + }); + syncInputs(); render(); + return; + } + // Legacy / full project file: restore everything const source = parsed.version ? { @@ -8961,6 +9095,8 @@ `; + } else if (state.templateMode === 'customart') { + select.innerHTML = ``; } else { select.innerHTML = ` @@ -9080,8 +9216,10 @@ } function attachEvents() { - for (const mode of ['cover', 'disc']) { - $(`mode-${mode}`).addEventListener('click', () => { + for (const mode of ['cover', 'disc', 'customart']) { + const btn = $(`mode-${mode}`); + if (!btn) continue; + btn.addEventListener('click', () => { state.templateMode = mode; if (mode === 'jewel') { state.preset = 'jewel8693'; @@ -9096,6 +9234,39 @@ $('preset').addEventListener('change', (event) => applyPreset(event.target.value)); + const caPresetEl = $('customArtPreset'); + if (caPresetEl) caPresetEl.addEventListener('change', (e) => { + const key = e.target.value; + const preset = CUSTOM_ART_PRESETS.find(p => p.key === key); + state.customArt = state.customArt || { width: 1920, height: 1080, background: '#ffffff', preset: '1920x1080' }; + state.customArt.preset = key; + if (preset && preset.key !== 'custom') { + state.customArt.width = preset.w; + state.customArt.height = preset.h; + } + syncInputs(); + render(); + }); + const setCustomArtSize = () => { + const w = Number($('customArtWidth')?.value) || state.customArt.width; + const h = Number($('customArtHeight')?.value) || state.customArt.height; + state.customArt = state.customArt || {}; + state.customArt.width = Math.max(CUSTOM_ART_MIN, Math.min(CUSTOM_ART_MAX, w)); + state.customArt.height = Math.max(CUSTOM_ART_MIN, Math.min(CUSTOM_ART_MAX, h)); + // Snap-detect: if size matches a preset, select it; else mark "custom". + const matched = CUSTOM_ART_PRESETS.find(p => p.key !== 'custom' && p.w === state.customArt.width && p.h === state.customArt.height); + state.customArt.preset = matched ? matched.key : 'custom'; + render(); + }; + const caW = $('customArtWidth'); if (caW) caW.addEventListener('input', setCustomArtSize); + const caH = $('customArtHeight'); if (caH) caH.addEventListener('input', setCustomArtSize); + const caBg = $('customArtBg'); + if (caBg) caBg.addEventListener('input', (e) => { + state.customArt = state.customArt || {}; + state.customArt.background = e.target.value; + render(); + }); + for (const id of ['panelW', 'trimH', 'spineW', 'bleed', 'safe']) { $(id).addEventListener('input', () => { setCustomWhenEdited();