This commit is contained in:
thePR0M3TH3AN committed 2026-05-16 20:17:38 -04:00
1 parent 88cf2bceab
commit f61ab7729f
1 file changed
+423 -82
+423 -82
View File
@@ -1506,6 +1506,7 @@
.feedTrustToggle select{font-size:.75rem;border:1px solid var(--line);background:var(--panel);color:var(--ink);border-radius:4px;padding:3px 6px;cursor:pointer}
body.dark .feedTrustToggle select{background:#0b1220}
body.nostr-logged-in .feedTrustToggle{display:flex}
.layerLockTag{display:inline-block;font-size:.58rem;font-weight:800;letter-spacing:.06em;color:var(--muted);background:rgba(127,127,127,.15);border:1px solid var(--line);border-radius:3px;padding:0 4px;margin-right:6px;vertical-align:1px}
.feedTrustBadge{font-size:.62rem;background:rgba(0,184,230,.12);color:var(--accent);border:1px solid rgba(0,184,230,.25);border-radius:4px;padding:1px 5px;margin-left:5px;white-space:nowrap;flex-shrink:0}
body.dark .feedTrustBadge{background:rgba(0,217,255,.1);border-color:rgba(0,217,255,.2)}
.feedCard.feedCardBlurred .feedCardThumb img,
@@ -1540,7 +1541,43 @@
.previewShell { min-height: 420px; }
}
body.exporting .slot,
body.exporting .discSlot,
body.exporting .jewelSlot {
border-color: transparent !important;
background: transparent !important;
}
body.exporting .placeholder,
body.exporting .slot::after, body.exporting .slot::before,
body.exporting .discSlot::before,
body.exporting .jewelSlot::before, body.exporting .jewelSlot::after,
body.exporting .jewelSpineGuide,
body.exporting .foldLine, body.exporting .trimArea, body.exporting .bleedFill,
body.exporting .spineText, body.exporting .discSpineStrip {
display: none !important;
}
body.exporting .freeLayer.selected {
outline: none !important;
}
body.exporting .freeLayer {
pointer-events: none !important;
}
body.exporting .sheet,
body.exporting .sheet *,
body.exporting .designerDiscWrap,
body.exporting .designerDiscWrap * {
-webkit-print-color-adjust: exact !important;
print-color-adjust: exact !important;
color-adjust: exact !important;
}
@media print {
html, body, .sheet, .sheet *, .freeLayer, .freeLayer * {
-webkit-print-color-adjust: exact !important;
print-color-adjust: exact !important;
color-adjust: exact !important;
}
html, body {
width: var(--sheet-w) !important;
height: var(--sheet-h) !important;
@@ -1566,16 +1603,21 @@
min-height: 0 !important;
}
html:has(body.template-cover) {
width: 11in !important;
height: 8.5in !important;
}
body.template-cover {
width: 8.5in !important;
height: 11in !important;
width: 11in !important;
height: 8.5in !important;
}
body.template-cover .app,
body.template-cover main,
body.template-cover .previewShell {
width: 8.5in !important;
height: 11in !important;
width: 11in !important;
height: 8.5in !important;
}
body.template-cover .previewShell {
@@ -1602,8 +1644,8 @@
body.template-cover .sheetWrap {
position: relative !important;
width: var(--sheet-h) !important;
height: var(--sheet-w) !important;
width: var(--sheet-w) !important;
height: var(--sheet-h) !important;
margin: 0 auto !important;
transform: none !important;
}
@@ -1619,13 +1661,13 @@
}
body.template-cover .sheet {
position: absolute !important;
position: fixed !important;
left: 50% !important;
top: 50% !important;
width: var(--sheet-w) !important;
height: var(--sheet-h) !important;
transform: translate(-50%, -50%) rotate(90deg) !important;
transform-origin: center center !important;
transform: translate(-50%, -50%) !important;
margin: 0 !important;
}
.slot, .discSlot, .jewelSlot {
@@ -1845,6 +1887,7 @@
<legend data-step="6">Print &amp; Save</legend>
<div class="buttonRow">
<button class="primary" id="printBtn">Print / Save PDF</button>
<button id="exportJpegBtn">Export JPEG</button>
</div>
<div class="buttonRow">
<button id="saveProjectBtn">Save project (JSON)</button>
@@ -2100,6 +2143,12 @@
</div>
<div id="textLayerControls">
<label for="textLayerContent">Text</label>
<textarea id="textLayerContent" rows="2" placeholder="Type your text here…" style="resize:vertical;width:100%"></textarea>
<label for="textFontFamily">Font</label>
<select id="textFontFamily"></select>
<label for="textFontSize">Text size, pt</label>
<input id="textFontSize" type="number" min="4" max="200" step="1" />
@@ -2119,9 +2168,20 @@
</div>
</div>
<div id="colorLayerControls" style="display:none">
<label for="colorLayerFill">Fill color</label>
<input id="colorLayerFill" type="color" />
</div>
<label style="display:flex;align-items:center;gap:8px;margin-top:8px;cursor:pointer;font-size:.84rem">
<input type="checkbox" id="layerLocked" style="accent-color:var(--accent)" />
Lock layer (prevent dragging on canvas)
</label>
<div class="buttonRow">
<button type="button" id="layerForward">Move forward</button>
<button type="button" id="layerBackward">Move back</button>
<button type="button" id="layerDuplicate">Duplicate</button>
<button type="button" class="danger" id="deleteLayer">Delete layer</button>
</div>
</details>
@@ -2159,6 +2219,7 @@
<legend data-step="4">Save &amp; Export</legend>
<div class="buttonRow">
<button type="button" id="designerSaveProjectBtn">Save design (JSON)</button>
<button type="button" id="designerExportJpegBtn">Export JPEG</button>
<button type="button" id="designerCloseProjectBtn">Close project</button>
</div>
<details>
@@ -2503,6 +2564,26 @@
const SCHEMA_VERSION = 5;
const MIN_SUPPORTED_SCHEMA_VERSION = 4;
const COLOR_LAYER_DEFAULT_FILL = '#3b82f6';
const COLOR_LAYER_DEFAULT_OPACITY = 0.6;
const WEB_SAFE_FONTS = [
{ label: 'System default', value: 'inherit' },
{ label: 'Sans · Arial', value: 'Arial, Helvetica, sans-serif' },
{ label: 'Sans · Helvetica', value: 'Helvetica, Arial, sans-serif' },
{ label: 'Sans · Verdana', value: 'Verdana, Geneva, sans-serif' },
{ label: 'Sans · Trebuchet', value: '"Trebuchet MS", sans-serif' },
{ label: 'Sans · Tahoma', value: 'Tahoma, Geneva, sans-serif' },
{ label: 'Sans · Arial Black', value: '"Arial Black", sans-serif' },
{ label: 'Sans · Impact', value: 'Impact, "Arial Narrow Bold", sans-serif' },
{ label: 'Serif · Times', value: '"Times New Roman", Times, serif' },
{ label: 'Serif · Georgia', value: 'Georgia, serif' },
{ label: 'Serif · Palatino', value: '"Palatino Linotype", "Book Antiqua", Palatino, serif' },
{ label: 'Mono · Courier', value: '"Courier New", Courier, monospace' },
{ label: 'Mono · Consolas', value: 'Consolas, "Courier New", monospace' },
{ label: 'Cursive · Comic Sans', value: '"Comic Sans MS", "Comic Sans", cursive' },
{ label: 'Cursive · Brush Script', value: '"Brush Script MT", cursive' }
];
const presets = {
dvd: {
name: 'DVD case wrap',
@@ -3334,25 +3415,25 @@
function preferredDiscProjectTitle() {
const topTitle = state.discMeta?.top?.title?.trim();
const bottomTitle = state.discMeta?.bottom?.title?.trim();
return topTitle || bottomTitle || '';
return topTitle || bottomTitle || (state.meta?.title || '');
}
function preferredDiscProjectImdb() {
const topImdb = state.discMeta?.top?.imdbId?.trim();
const bottomImdb = state.discMeta?.bottom?.imdbId?.trim();
return topImdb || bottomImdb || '';
return topImdb || bottomImdb || (state.meta?.imdbId || '');
}
function preferredDiscProjectCategory() {
const topCategory = state.discMeta?.top?.category?.trim();
const bottomCategory = state.discMeta?.bottom?.category?.trim();
return topCategory || bottomCategory || '';
return topCategory || bottomCategory || (state.meta?.category || '');
}
function preferredDiscProjectLanguage() {
const topLanguage = state.discMeta?.top?.language?.trim();
const bottomLanguage = state.discMeta?.bottom?.language?.trim();
return topLanguage || bottomLanguage || 'en';
return topLanguage || bottomLanguage || (state.meta?.language || 'en');
}
function syncProjectMetaFromDiscMeta(force = false) {
@@ -3517,8 +3598,8 @@
root.style.setProperty('--bleed', inches(state.bleed));
root.style.setProperty('--safe', inches(state.safe));
const pageW = state.templateMode === 'cover' ? 8.5 : sheetW();
const pageH = state.templateMode === 'cover' ? 11 : sheetH();
const pageW = state.templateMode === 'cover' ? 11 : sheetW();
const pageH = state.templateMode === 'cover' ? 8.5 : sheetH();
$('dynamicPageStyle').textContent = `@page { size: ${fmt(pageW)}in ${fmt(pageH)}in; margin: 0; }`;
if (state.templateMode === 'disc') {
@@ -4477,6 +4558,7 @@
rotate: 0,
opacity: 1,
z: nextLayerZ(),
fontFamily: 'inherit',
fontSize: 24,
color: '#111827',
align: 'center',
@@ -4485,6 +4567,27 @@
});
}
function addColorLayer(targetOverride = '') {
const target = targetOverride || $('layerTarget')?.value || 'page';
const bounds = targetBounds(target);
applyTargetMode(target);
addLayer({
id: makeLayerId(),
type: 'color',
name: 'Color block',
target,
color: COLOR_LAYER_DEFAULT_FILL,
x: bounds.x,
y: bounds.y,
w: bounds.w,
h: bounds.h,
rotate: 0,
opacity: COLOR_LAYER_DEFAULT_OPACITY,
locked: true,
z: nextLayerZ()
});
}
function renderLayers() {
const overlay = $('layerOverlay');
const designerOverlay = $('designerLayerOverlay');
@@ -4515,18 +4618,25 @@
el.style.opacity = layer.opacity;
el.style.zIndex = layer.z || 1;
el.style.transform = `rotate(${layer.rotate || 0}deg)`;
if (layer.locked) {
el.style.pointerEvents = 'none';
el.classList.add('layerLocked');
}
if (layer.type === 'image') {
const img = document.createElement('img');
img.src = layer.src;
img.alt = layer.name || 'Image layer';
el.appendChild(img);
} else if (layer.type === 'color') {
el.style.background = layer.color || COLOR_LAYER_DEFAULT_FILL;
} else {
const text = document.createElement('div');
text.className = 'textLayer';
text.contentEditable = 'true';
text.spellcheck = false;
text.innerHTML = layer.html || '';
text.style.fontFamily = layer.fontFamily || 'inherit';
text.style.fontSize = `${layer.fontSize || 24}pt`;
text.style.color = layer.color || '#111827';
text.style.textAlign = layer.align || 'left';
@@ -4576,9 +4686,12 @@
const item = document.createElement('button');
item.type = 'button';
item.className = `layerItem${layer.id === state.selectedLayerId ? ' active' : ''}`;
const rawName = layer.name || layer.type;
const rawName = layer.type === 'text'
? (layerHtmlToPlainText(layer.html).split('\n')[0].trim() || layer.name || 'Text layer')
: (layer.name || layer.type);
const displayName = rawName.length > 32 ? rawName.slice(0, 29) + '...' : rawName;
item.innerHTML = `<span title="${rawName}">${displayName}</span><span>${layer.type}</span>`;
const lockTag = layer.locked ? '<span class="layerLockTag" title="Locked">LOCK</span>' : '';
item.innerHTML = `<span title="${rawName}">${lockTag}${displayName}</span><span>${layer.type}</span>`;
item.addEventListener('click', () => {
state.selectedLayerId = layer.id;
render();
@@ -4635,7 +4748,11 @@
addTextBtn.type = 'button';
addTextBtn.textContent = 'Add text layer';
addTextBtn.addEventListener('click', () => addTextLayer(targetDef.key));
buttonRow.append(addImageBtn, addTextBtn);
const addColorBtn = document.createElement('button');
addColorBtn.type = 'button';
addColorBtn.textContent = 'Add color block';
addColorBtn.addEventListener('click', () => addColorLayer(targetDef.key));
buttonRow.append(addImageBtn, addTextBtn, addColorBtn);
actions.append(urlRow, buttonRow);
panel.appendChild(actions);
@@ -4655,9 +4772,12 @@
const item = document.createElement('button');
item.type = 'button';
item.className = `layerItem${layer.id === state.selectedLayerId ? ' active' : ''}`;
const rawName = layer.name || layer.type;
const rawName = layer.type === 'text'
? (layerHtmlToPlainText(layer.html).split('\n')[0].trim() || layer.name || 'Text layer')
: (layer.name || layer.type);
const displayName = rawName.length > 32 ? rawName.slice(0, 29) + '...' : rawName;
item.innerHTML = `<span title="${rawName}">${displayName}</span><span>${layer.type}</span>`;
const lockTag = layer.locked ? '<span class="layerLockTag" title="Locked">LOCK</span>' : '';
item.innerHTML = `<span title="${rawName}">${lockTag}${displayName}</span><span>${layer.type}</span>`;
item.addEventListener('click', () => {
state.selectedLayerId = layer.id;
render();
@@ -5524,20 +5644,48 @@
return getLayer(state.selectedLayerId);
}
function layerHtmlToPlainText(html) {
if (!html) return '';
const tmp = document.createElement('div');
tmp.innerHTML = String(html).replace(/<br\s*\/?>/gi, '\n').replace(/<\/(?:div|p)>/gi, '\n').replace(/<(?:div|p)[^>]*>/gi, '');
return (tmp.textContent || '').replace(/\n{3,}/g, '\n\n');
}
function layerPlainTextToHtml(text) {
return escapeHtml(String(text || '')).replace(/\n/g, '<br>');
}
function syncLayerInputs() {
const layer = selectedLayer();
$('layerControls').open = Boolean(layer);
$('textLayerControls').style.display = layer && layer.type === 'text' ? 'block' : 'none';
$('layerImageControls').style.display = layer && layer.type === 'image' ? 'block' : 'none';
const colorPanel = $('colorLayerControls');
if (colorPanel) colorPanel.style.display = layer && layer.type === 'color' ? 'block' : 'none';
const layerNameRow = $('layerName');
const layerNameLabel = layerNameRow?.previousElementSibling;
const hideName = layer && layer.type === 'text';
if (layerNameRow) layerNameRow.style.display = hideName ? 'none' : '';
if (layerNameLabel && layerNameLabel.tagName === 'LABEL' && layerNameLabel.getAttribute('for') === 'layerName') {
layerNameLabel.style.display = hideName ? 'none' : '';
}
for (const id of ['layerName', 'layerX', 'layerY', 'layerW', 'layerH', 'layerRotate', 'layerOpacity']) {
$(id).disabled = !layer;
}
$('layerUrl').disabled = !layer || layer.type !== 'image';
for (const id of ['textFontSize', 'textColor', 'textAlign', 'textBold', 'textItalic']) {
$(id).disabled = !layer || layer.type !== 'text';
for (const id of ['textLayerContent', 'textFontFamily', 'textFontSize', 'textColor', 'textAlign', 'textBold', 'textItalic']) {
const el = $(id);
if (el) el.disabled = !layer || layer.type !== 'text';
}
for (const id of ['layerForward', 'layerBackward', 'deleteLayer']) {
$(id).disabled = !layer;
const fillEl = $('colorLayerFill');
if (fillEl) fillEl.disabled = !layer || layer.type !== 'color';
for (const id of ['layerForward', 'layerBackward', 'layerDuplicate', 'deleteLayer']) {
const el = $(id);
if (el) el.disabled = !layer;
}
const lockEl = $('layerLocked');
if (lockEl) {
lockEl.disabled = !layer;
lockEl.checked = !!(layer && layer.locked);
}
if (!layer) return;
@@ -5554,10 +5702,18 @@
}
if (layer.type === 'text') {
const content = $('textLayerContent');
if (content && document.activeElement !== content) content.value = layerHtmlToPlainText(layer.html);
const ff = $('textFontFamily');
if (ff) ff.value = layer.fontFamily || 'inherit';
$('textFontSize').value = layer.fontSize || 24;
$('textColor').value = layer.color || '#111827';
$('textAlign').value = layer.align || 'left';
}
if (layer.type === 'color' && fillEl) {
fillEl.value = layer.color || COLOR_LAYER_DEFAULT_FILL;
}
}
function updateSelectedLayer(prop, value) {
@@ -5571,6 +5727,7 @@
const el = event.currentTarget;
const layer = getLayer(el.dataset.layerId);
if (!layer) return;
if (layer.locked) { state.selectedLayerId = layer.id; render(); return; }
state.selectedLayerId = layer.id;
const textTarget = event.target.closest('.textLayer');
@@ -5792,6 +5949,82 @@
URL.revokeObjectURL(url);
}
let _html2canvasPromise = null;
function loadHtml2Canvas() {
if (window.html2canvas) return Promise.resolve(window.html2canvas);
if (_html2canvasPromise) return _html2canvasPromise;
_html2canvasPromise = new Promise((resolve, reject) => {
const s = document.createElement('script');
s.src = 'https://cdn.jsdelivr.net/npm/[email protected]/dist/html2canvas.min.js';
s.onload = () => resolve(window.html2canvas);
s.onerror = () => { _html2canvasPromise = null; reject(new Error('Could not load the image-export library. Check your network connection.')); };
document.head.appendChild(s);
});
return _html2canvasPromise;
}
function jpegFilenameStem() {
const parts = [
filenamePart(state.meta?.title) || 'design',
filenamePart(state.meta?.category),
state.appMode === 'designer' ? 'disc_design' : state.templateMode
].filter(Boolean);
return parts.join('_');
}
async function exportArtboardAsJpeg(button) {
const originalLabel = button?.textContent;
const originalSelectedId = state.selectedLayerId;
const sheetWrapEl = $('sheetWrap');
const originalSheetWrapTransform = sheetWrapEl?.style.transform || '';
const targetEl = state.appMode === 'designer' ? $('designerDiscWrap') : $('sheet');
if (!targetEl) return;
try {
if (button) { button.disabled = true; button.textContent = 'Rendering…'; }
if (originalSelectedId) { state.selectedLayerId = ''; }
if (sheetWrapEl) sheetWrapEl.style.transform = 'none';
document.body.classList.add('exporting');
render();
await new Promise(res => requestAnimationFrame(() => requestAnimationFrame(() => setTimeout(res, 100))));
await Promise.all([...targetEl.querySelectorAll('img')].map(img => {
if (img.complete && img.naturalHeight) return Promise.resolve();
return new Promise(res => {
img.addEventListener('load', () => res(), { once: true });
img.addEventListener('error', () => res(), { once: true });
});
}));
const html2canvas = await loadHtml2Canvas();
const rect = targetEl.getBoundingClientRect();
const scale = Math.max(2, Math.min(4, 1800 / Math.max(rect.width, rect.height)));
const canvas = await html2canvas(targetEl, {
scale,
useCORS: true,
allowTaint: false,
backgroundColor: '#ffffff',
logging: false,
imageTimeout: 10000
});
const blob = await new Promise(res => canvas.toBlob(res, 'image/jpeg', 0.92));
if (!blob) throw new Error('Could not encode JPEG. One of your images is from a server that blocks cross-origin reads — try a different image host.');
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${jpegFilenameStem()}.jpg`;
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 1000);
} catch (err) {
alert(err.message || 'Could not export JPEG.');
} finally {
document.body.classList.remove('exporting');
if (sheetWrapEl) sheetWrapEl.style.transform = originalSheetWrapTransform;
if (originalSelectedId) { state.selectedLayerId = originalSelectedId; }
render();
if (button) { button.disabled = false; button.textContent = originalLabel; }
}
}
function downloadProject() {
const title = (state.meta.title || '').trim();
const category = (state.meta.category || '').trim();
@@ -5823,62 +6056,74 @@
triggerDownload(payload, filename);
}
function closeDiscDesignerProject() {
const hasContent = state.discDesign?.src || (state.layers && state.layers.some(l => l.target === 'designer-disc'));
if (!hasContent) {
state.discDesign = { name: '', src: '', fit: 'cover', zoom: 1, x: 0, y: 0, rotate: 0 };
state.layers = state.layers.filter(l => l.target !== 'designer-disc');
state.meta = { title: "", imdbId: "", omdbApiKey: "", category: "", language: "en" };
render();
return;
}
const action = confirm('Save this design before closing?\n\nOK = Save to file\nCancel = Close without saving');
if (action) {
try {
downloadSingleDesign();
} catch (e) {
const publishAnyway = confirm(`Could not save: ${e.message}\n\nPublish to Nostr instead?`);
if (publishAnyway) {
try {
publishDiscDesignToNostr();
} catch (e2) {
alert(`Error: ${e2.message}`);
return;
}
} else {
return;
}
}
}
function resetDiscDesignerState() {
state.discDesign = { name: '', src: '', fit: 'cover', zoom: 1, x: 0, y: 0, rotate: 0 };
state.layers = state.layers.filter(l => l.target !== 'designer-disc');
state.meta = { title: "", imdbId: "", omdbApiKey: "", category: "", language: "en" };
state.layers = (state.layers || []).filter(l => l.target !== 'designer-disc');
state.meta = { title: '', imdbId: '', omdbApiKey: '', category: '', language: 'en' };
state.selectedLayerId = '';
syncInputs();
syncDesignerInputs();
render();
}
function closeTemplateProject() {
const hasContent = state.discs.top?.src || state.discs.bottom?.src || state.combined?.src || Object.values(state.slots || {}).some(s => s?.src);
if (!hasContent) {
state.discs = { top: { src: '', fit: 'cover', zoom: 1, x: 0, y: 0, rotate: 0 }, bottom: { src: '', fit: 'cover', zoom: 1, x: 0, y: 0, rotate: 0 } };
state.discMeta = normalizeDiscMeta();
state.slots = { back: { src: '', fit: 'cover', zoom: 1, x: 0, y: 0, rotate: 0 }, spine: { src: '', fit: 'cover', zoom: 1, x: 0, y: 0, rotate: 0 }, front: { src: '', fit: 'cover', zoom: 1, x: 0, y: 0, rotate: 0 } };
state.combined = { src: '' };
state.meta = { title: "", imdbId: "", omdbApiKey: "", category: "", language: "en" };
render();
return;
}
const action = confirm('Save this project before closing?\n\nOK = Save to file\nCancel = Close without saving');
if (action) {
downloadProject();
function closeDiscDesignerProject() {
const hasContent = state.discDesign?.src || (state.layers && state.layers.some(l => l.target === 'designer-disc'));
if (hasContent) {
const action = confirm('Save this design before closing?\n\nOK = Save to file\nCancel = Close without saving');
if (action) {
try {
downloadSingleDesign();
} catch (e) {
const publishAnyway = confirm(`Could not save: ${e.message}\n\nPublish to Nostr instead?`);
if (publishAnyway) {
try {
publishDiscDesignToNostr();
} catch (e2) {
alert(`Error: ${e2.message}`);
return;
}
} else {
return;
}
}
}
}
resetDiscDesignerState();
}
function resetTemplateProjectState() {
state.discs = { top: { src: '', fit: 'cover', zoom: 1, x: 0, y: 0, rotate: 0 }, bottom: { src: '', fit: 'cover', zoom: 1, x: 0, y: 0, rotate: 0 } };
state.discMeta = normalizeDiscMeta();
state.slots = { back: { src: '', fit: 'cover', zoom: 1, x: 0, y: 0, rotate: 0 }, spine: { src: '', fit: 'cover', zoom: 1, x: 0, y: 0, rotate: 0 }, front: { src: '', fit: 'cover', zoom: 1, x: 0, y: 0, rotate: 0 } };
state.combined = { src: '' };
state.meta = { title: "", imdbId: "", omdbApiKey: "", category: "", language: "en" };
state.jewelInserts = { front: { src: '', fit: 'cover', zoom: 1, x: 0, y: 0, rotate: 0 }, tray: { src: '', fit: 'cover', zoom: 1, x: 0, y: 0, rotate: 0 } };
state.discSpineLabels = { top: '', bottom: '' };
state.jewelSpineLabels = { left: '', right: '' };
state.meta = { title: '', imdbId: '', omdbApiKey: '', category: '', language: 'en' };
state.layers = (state.layers || []).filter(l => l.target === 'designer-disc');
state.selectedLayerId = '';
syncInputs();
syncAllSlotInputs();
syncAllDiscInputs();
syncAllJewelInputs();
syncCombinedInputs();
render();
}
function closeTemplateProject() {
const hasSlotContent = state.discs.top?.src || state.discs.bottom?.src || state.combined?.src || Object.values(state.slots || {}).some(s => s?.src);
const hasJewel = Object.values(state.jewelInserts || {}).some(i => i?.src);
const hasLayers = (state.layers || []).some(l => l.target !== 'designer-disc' && !l._generatedDiscLayer);
const hasSpine = !!(state.discSpineLabels?.top || state.discSpineLabels?.bottom || state.jewelSpineLabels?.left || state.jewelSpineLabels?.right);
const hasTitle = !!(state.meta?.title?.trim());
const hasContent = hasSlotContent || hasJewel || hasLayers || hasSpine || hasTitle;
if (hasContent) {
const action = confirm('Save this project before closing?\n\nOK = Save to file\nCancel = Close without saving');
if (action) downloadProject();
}
resetTemplateProjectState();
}
function loadProjectFromText(text) {
const parsed = JSON.parse(text);
if (parsed.version && parsed.templateMode === 'disc-design' && parsed.discDesign) {
@@ -7205,8 +7450,10 @@
if (layer.type === 'text') {
const fontPx = (layer.fontSize || 14) * (96/72) * SCALE;
el.style.cssText += `color:${layer.color||'#fff'};font-size:${fontPx}px;font-weight:${layer.bold?'bold':'normal'};font-style:${layer.italic?'italic':'normal'};text-align:${layer.align||'left'};display:flex;align-items:flex-start;`;
el.style.cssText += `font-family:${layer.fontFamily || 'inherit'};color:${layer.color||'#fff'};font-size:${fontPx}px;font-weight:${layer.bold?'bold':'normal'};font-style:${layer.italic?'italic':'normal'};text-align:${layer.align||'left'};display:flex;align-items:flex-start;`;
el.innerHTML = layer.html || '';
} else if (layer.type === 'color') {
el.style.background = layer.color || COLOR_LAYER_DEFAULT_FILL;
} else if (layer.type === 'image' && layer.src) {
const img = document.createElement('img');
img.src = layer.src;
@@ -7377,10 +7624,57 @@
preview.appendChild(el);
});
const coverLayers = (Array.isArray(payload.layers) ? payload.layers : [])
.filter(l => {
const t = String(l?.target || '');
return !t.startsWith('disc') && !t.startsWith('jewel') && !t.startsWith('designer-') && !l._generatedDiscLayer;
})
.slice()
.sort((a, b) => (a.z || 0) - (b.z || 0));
const renderCoverLayer = (wrap, layer) => {
const el = document.createElement('div');
const lx = ((Number(layer.x) || 0) - bleed) / totalW * 100;
const ly = ((Number(layer.y) || 0) - bleed) / trimH * 100;
const lw = (Number(layer.w) || totalW) / totalW * 100;
const lh = (Number(layer.h) || trimH) / trimH * 100;
el.style.cssText = [
'position:absolute',
`left:${lx}%`,
`top:${ly}%`,
`width:${lw}%`,
`height:${lh}%`,
`transform:rotate(${Number(layer.rotate) || 0}deg)`,
`opacity:${layer.opacity !== undefined ? layer.opacity : 1}`,
'overflow:hidden',
'pointer-events:none',
'box-sizing:border-box',
'z-index:4'
].join(';');
if (layer.type === 'text') {
const scale = preview.clientWidth ? (preview.clientWidth / (totalW * 96)) : 1;
const align = layer.align || 'left';
el.style.cssText += `font-family:${layer.fontFamily || 'inherit'};color:${layer.color || '#fff'};font-size:${((layer.fontSize || 14) * (96 / 72) * scale)}px;font-weight:${layer.bold ? '800' : '400'};font-style:${layer.italic ? 'italic' : 'normal'};text-align:${align};display:flex;align-items:flex-start;justify-content:${align === 'center' ? 'center' : align === 'right' ? 'flex-end' : 'flex-start'};`;
el.innerHTML = layer.html || '';
} else if (layer.type === 'color') {
el.style.background = layer.color || COLOR_LAYER_DEFAULT_FILL;
} else if (layer.type === 'image' && layer.src) {
const img = document.createElement('img');
img.src = layer.src;
img.alt = layer.name || '';
img.style.cssText = `width:100%;height:100%;object-fit:${layer.fit || 'contain'};`;
img.addEventListener('error', () => img.remove());
el.appendChild(img);
}
wrap.appendChild(el);
};
coverLayers.forEach(layer => renderCoverLayer(preview, layer));
const row = document.createElement('div');
row.appendChild(preview);
const hasArtwork = (isCombined && combined.src) || ['back','spine','front'].some(key => slots[key]?.src);
const hasArtwork = (isCombined && combined.src) || ['back','spine','front'].some(key => slots[key]?.src) || coverLayers.length > 0;
if (!hasArtwork) {
if (payload.preview) {
const img = document.createElement('img');
@@ -7439,8 +7733,10 @@
if (layer.type === 'text') {
const scale = wrap.clientWidth && layerScale ? (wrap.clientWidth / (layerScale * 96)) : 1;
const align = layer.align || 'left';
el.style.cssText += `color:${layer.color || '#fff'};font-size:${((layer.fontSize || 14) * (96 / 72) * scale)}px;font-weight:${layer.bold ? '800' : '400'};font-style:${layer.italic ? 'italic' : 'normal'};text-align:${align};display:flex;align-items:flex-start;justify-content:${align === 'center' ? 'center' : align === 'right' ? 'flex-end' : 'flex-start'};`;
el.style.cssText += `font-family:${layer.fontFamily || 'inherit'};color:${layer.color || '#fff'};font-size:${((layer.fontSize || 14) * (96 / 72) * scale)}px;font-weight:${layer.bold ? '800' : '400'};font-style:${layer.italic ? 'italic' : 'normal'};text-align:${align};display:flex;align-items:flex-start;justify-content:${align === 'center' ? 'center' : align === 'right' ? 'flex-end' : 'flex-start'};`;
el.innerHTML = layer.html || '';
} else if (layer.type === 'color') {
el.style.background = layer.color || COLOR_LAYER_DEFAULT_FILL;
} else if (layer.type === 'image' && layer.src) {
const img = document.createElement('img');
img.src = layer.src;
@@ -7711,8 +8007,10 @@
if (layer.type === 'text') {
const scale = wrap.clientWidth ? (wrap.clientWidth / (slotW * 96)) : 1;
const align = layer.align || 'left';
el.style.cssText += `color:${layer.color || '#111'};font-size:${((layer.fontSize || 14) * (96 / 72) * scale)}px;font-weight:${layer.bold ? '800' : '400'};font-style:${layer.italic ? 'italic' : 'normal'};text-align:${align};display:flex;align-items:flex-start;justify-content:${align === 'center' ? 'center' : align === 'right' ? 'flex-end' : 'flex-start'};`;
el.style.cssText += `font-family:${layer.fontFamily || 'inherit'};color:${layer.color || '#111'};font-size:${((layer.fontSize || 14) * (96 / 72) * scale)}px;font-weight:${layer.bold ? '800' : '400'};font-style:${layer.italic ? 'italic' : 'normal'};text-align:${align};display:flex;align-items:flex-start;justify-content:${align === 'center' ? 'center' : align === 'right' ? 'flex-end' : 'flex-start'};`;
el.innerHTML = layer.html || '';
} else if (layer.type === 'color') {
el.style.background = layer.color || COLOR_LAYER_DEFAULT_FILL;
} else if (layer.type === 'image' && layer.src) {
const img = document.createElement('img');
img.src = layer.src;
@@ -8248,6 +8546,10 @@
$('saveProjectBtn').addEventListener('click', downloadProject);
$('closeProjectBtn').addEventListener('click', closeTemplateProject);
const exportJpegBtn = $('exportJpegBtn');
if (exportJpegBtn) exportJpegBtn.addEventListener('click', () => exportArtboardAsJpeg(exportJpegBtn));
const designerExportJpegBtn = $('designerExportJpegBtn');
if (designerExportJpegBtn) designerExportJpegBtn.addEventListener('click', () => exportArtboardAsJpeg(designerExportJpegBtn));
$('designerSaveProjectBtn').addEventListener('click', () => {
try {
downloadSingleDesign();
@@ -8569,9 +8871,27 @@
$(id).addEventListener('input', (event) => updateSelectedLayer(prop, Number(event.target.value)));
}
const textContentEl = $('textLayerContent');
if (textContentEl) {
textContentEl.addEventListener('input', (event) => {
const layer = selectedLayer();
if (!layer || layer.type !== 'text') return;
layer.html = layerPlainTextToHtml(event.target.value);
render();
});
}
const fontFamilyEl = $('textFontFamily');
if (fontFamilyEl) {
fontFamilyEl.innerHTML = WEB_SAFE_FONTS.map(f => `<option value="${escapeHtml(f.value)}" style="font-family:${f.value === 'inherit' ? 'inherit' : escapeHtml(f.value)}">${escapeHtml(f.label)}</option>`).join('');
fontFamilyEl.addEventListener('change', (event) => updateSelectedLayer('fontFamily', event.target.value));
}
$('textFontSize').addEventListener('input', (event) => updateSelectedLayer('fontSize', Number(event.target.value)));
$('textColor').addEventListener('input', (event) => updateSelectedLayer('color', event.target.value));
$('textAlign').addEventListener('change', (event) => updateSelectedLayer('align', event.target.value));
const colorFillEl = $('colorLayerFill');
if (colorFillEl) colorFillEl.addEventListener('input', (event) => updateSelectedLayer('color', event.target.value));
const lockEl = $('layerLocked');
if (lockEl) lockEl.addEventListener('change', (event) => updateSelectedLayer('locked', event.target.checked));
$('textBold').addEventListener('click', () => {
const layer = selectedLayer();
@@ -8587,20 +8907,25 @@
render();
});
$('layerForward').addEventListener('click', () => {
function moveLayerStep(direction) {
const layer = selectedLayer();
if (!layer) return;
layer.z = nextLayerZ();
const curZ = layer.z || 0;
const peers = state.layers
.filter(l => l !== layer && l.target === layer.target && !l._generatedDiscLayer)
.sort((a, b) => (a.z || 0) - (b.z || 0));
const swap = direction === 'forward'
? peers.find(l => (l.z || 0) > curZ)
: [...peers].reverse().find(l => (l.z || 0) < curZ);
if (!swap) return;
const swapZ = swap.z || 0;
swap.z = curZ;
layer.z = swapZ;
render();
});
}
$('layerBackward').addEventListener('click', () => {
const layer = selectedLayer();
if (!layer) return;
const minZ = state.layers.reduce((min, item) => Math.min(min, item.z || 0), layer.z || 0);
layer.z = minZ - 1;
render();
});
$('layerForward').addEventListener('click', () => moveLayerStep('forward'));
$('layerBackward').addEventListener('click', () => moveLayerStep('backward'));
$('deleteLayer').addEventListener('click', () => {
if (!state.selectedLayerId) return;
@@ -8609,6 +8934,22 @@
render();
});
const duplicateBtn = $('layerDuplicate');
if (duplicateBtn) duplicateBtn.addEventListener('click', () => {
const layer = selectedLayer();
if (!layer) return;
const OFFSET = 0.1;
const copy = {
...layer,
id: makeLayerId(),
x: (Number(layer.x) || 0) + OFFSET,
y: (Number(layer.y) || 0) + OFFSET,
z: nextLayerZ(),
name: layer.name ? `${layer.name} copy` : layer.name
};
addLayer(copy);
});
window.addEventListener('keydown', (event) => {
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'p') {
render();