diff --git a/src/index.html b/src/index.html index d7bc749..897a6ac 100644 --- a/src/index.html +++ b/src/index.html @@ -105,6 +105,11 @@ main { min-width: 0; padding: 26px; + display: flex; + flex-direction: column; + gap: 8px; + max-height: 100vh; + box-sizing: border-box; } .appHeader { @@ -624,7 +629,8 @@ border-radius: 0px; padding: 24px; overflow: auto; - min-height: calc(100vh - 112px); + flex: 1; + min-height: 0; box-shadow: inset 0 1px 0 rgba(255,255,255,.7); } @@ -1638,6 +1644,31 @@ .svgElementRow input[type="color"]::-webkit-color-swatch-wrapper { padding:0; } .svgElementRow input[type="color"]::-webkit-color-swatch { border:0; border-radius:3px; } + /* Off-canvas veil: a transparent box covering the sheet, with a massive + semi-opaque box-shadow that fills everything outside the sheet bounds. + Layer content that extends past the artboard sits visually under this + shadow so it looks faded, making the canvas edges crystal clear. */ + .canvasVeil { position:absolute; inset:0; pointer-events:none; z-index:999; background:transparent; box-shadow:0 0 0 9999px rgba(229,231,235,.62); } + body.dark .canvasVeil { box-shadow:0 0 0 9999px rgba(15,22,34,.62); } + @media print { .canvasVeil { display:none !important; } } + body.export-print .canvasVeil { display:none !important; } + body.exporting .canvasVeil { display:none !important; } + + .canvasToolbar { display:flex; align-items:center; gap:6px; padding:6px 10px; background:rgba(255,255,255,.95); border:1px solid var(--line); border-radius:8px; z-index:6; box-shadow:0 4px 12px rgba(0,0,0,.08); margin:0 auto; width:fit-content; max-width:100%; flex-shrink:0; } + body.dark .canvasToolbar { background:rgba(15,22,34,.92); } + body.app-designer .canvasToolbar { display:none; } + .canvasToolbarBtn { padding:4px 10px; font-size:.82rem; border:1px solid var(--line); border-radius:6px; background:var(--panel); color:var(--ink); cursor:pointer; min-width:32px; } + .canvasToolbarBtn:hover { filter:brightness(.97); } + .canvasToolbarSlider { flex:1; min-width:120px; max-width:280px; } + .canvasToolbarReadout { font-size:.78rem; color:var(--muted); min-width:80px; text-align:right; font-variant-numeric:tabular-nums; } + .canvasToolbarDivider { width:1px; height:18px; background:var(--line); margin:0 4px; } + @media print { .canvasToolbar { display:none !important; } } + + .undoRedoGroup { display:flex; gap:4px; align-items:center; } + .undoRedoBtn { width:30px; height:30px; padding:0; font-size:1.1rem; line-height:1; border:1px solid var(--line); border-radius:6px; background:var(--panel); color:var(--ink); cursor:pointer; } + .undoRedoBtn:hover:not(:disabled) { background:rgba(127,127,127,.12); } + .undoRedoBtn:disabled { opacity:.4; cursor:not-allowed; } + .aspectLockRow { display:flex; align-items:flex-end; gap:6px; } .aspectLockRow > div { flex:1; min-width:0; } .aspectLockBtn { background:transparent; border:1px solid var(--line); border-radius:6px; padding:6px 8px; cursor:pointer; font-size:.9rem; line-height:1; align-self:flex-end; flex-shrink:0; margin-bottom:2px; } @@ -2659,6 +2690,10 @@

Left-to-right layout is back cover, spine, front cover.

+
+ + +
@@ -2735,6 +2770,7 @@
+ @@ -2748,6 +2784,16 @@
Design A
+ +
+ + + + 60% + + + +
@@ -3414,6 +3460,7 @@ bleed: presets.dvd.bleed, safe: presets.dvd.safe, zoom: 60, + zoomAuto: true, showGuides: true, showBleed: true, showCrops: true, @@ -4272,6 +4319,7 @@ } function applyPreset(name) { + if (!_isRestoring) pushHistory(`apply preset: ${name}`); state.preset = name; if (name === 'jewel8693') { state.templateMode = 'jewel'; @@ -4496,10 +4544,60 @@ `; } + // Auto-fit: compute a zoom that lands the sheet inside the preview + // viewport (with a small padding margin) every render until the user + // touches the zoom controls themselves. + if (state.zoomAuto) { + const fit = computeFitZoom(); + if (fit > 0) state.zoom = fit; + } const scale = state.zoom / 100; $('sheetWrap').style.transform = `scale(${scale})`; $('sheetWrap').style.width = `${sheetW() * 96 * scale}px`; $('sheetWrap').style.height = `${sheetH() * 96 * scale}px`; + syncZoomControls(); + } + + function computeFitZoom() { + const shell = $('previewShell'); + if (!shell) return 0; + const padding = 32; // visual breathing room on each side + const availW = Math.max(40, shell.clientWidth - padding); + const availH = Math.max(40, shell.clientHeight - padding); + const sheetWpx = sheetW() * 96; + const sheetHpx = sheetH() * 96; + if (sheetWpx <= 0 || sheetHpx <= 0) return 0; + const fit = Math.min(availW / sheetWpx, availH / sheetHpx) * 100; + return Math.max(5, Math.min(200, Math.round(fit))); + } + + function syncZoomControls() { + const out = $('zoomOut'); if (out) out.textContent = `${state.zoom}%`; + const sidebarSlider = $('zoom'); if (sidebarSlider) sidebarSlider.value = state.zoom; + const bottomSlider = $('zoomSliderBottom'); if (bottomSlider) bottomSlider.value = state.zoom; + const bottomOut = $('zoomReadoutBottom'); if (bottomOut) { + bottomOut.textContent = state.zoomAuto ? `Fit (${state.zoom}%)` : `${state.zoom}%`; + } + } + + function setManualZoom(percent) { + const z = Math.max(5, Math.min(200, Math.round(Number(percent) || 0))); + state.zoomAuto = false; + state.zoom = z; + // Direct DOM scale write to skip the full render path on a slider drag. + const scale = z / 100; + const wrap = $('sheetWrap'); + if (wrap) { + wrap.style.transform = `scale(${scale})`; + wrap.style.width = `${sheetW() * 96 * scale}px`; + wrap.style.height = `${sheetH() * 96 * scale}px`; + } + syncZoomControls(); + } + + function resetZoomAuto() { + state.zoomAuto = true; + render(); } // Keep the container's crop rectangle stationary while rotating/panning the image. @@ -4610,6 +4708,128 @@ } } + // ---- Undo / Redo (per docs/UNDO_REDO_FEATURE.md) ---- + + // Editor-state fields that are snapshotted. Ambient state (community + // feeds, caches, identity, transient modal targets) is intentionally + // excluded so undo doesn't roll back network actions or UI state. + const UNDO_SNAPSHOT_FIELDS = [ + 'appMode', 'templateMode', + 'savedMetaByMode', + 'meta', + 'preset', 'panelW', 'trimH', 'spineW', 'bleed', 'safe', 'artworkMode', + 'slots', 'combined', + 'discs', 'discMeta', 'discSpineLabels', + 'jewelInserts', 'jewelSpineLabels', + 'customArt', + 'discDesign', + 'layers', + 'selectedLayerId', + 'editingDTag', 'editingKind', 'editingEventId', 'editingTitle', + 'showGuides', 'showBleed', 'showCrops', 'printGuides', 'printCrops', 'zoom' + ]; + const UNDO_MAX = 50; + const _undoStack = []; + const _redoStack = []; + let _isRestoring = false; + const _undoDebounceTimers = new Map(); + + function cloneStateSnapshot() { + const snap = {}; + for (const k of UNDO_SNAPSHOT_FIELDS) snap[k] = state[k]; + try { return structuredClone(snap); } + catch { return JSON.parse(JSON.stringify(snap)); } + } + + function restoreStateSnapshot(snapshot) { + _isRestoring = true; + try { + for (const k of UNDO_SNAPSHOT_FIELDS) { + if (k in snapshot) state[k] = snapshot[k]; + } + // Re-derive computed downstream state. + try { normalizeDiscState?.(); } catch {} + try { normalizeJewelState?.(); } catch {} + try { paintEditingBanner?.(); } catch {} + try { syncModeTabsUI?.(); } catch {} + try { syncInputs(); } catch {} + try { syncAllSlotInputs?.(); } catch {} + try { syncAllDiscInputs?.(); } catch {} + try { syncAllJewelInputs?.(); } catch {} + try { syncCombinedInputs?.(); } catch {} + try { syncDesignerInputs?.(); } catch {} + render(); + } finally { + _isRestoring = false; + } + updateUndoRedoUI(); + } + + function pushHistory(label) { + if (_isRestoring) return; + const snap = cloneStateSnapshot(); + _undoStack.push({ label: label || 'change', snapshot: snap, at: Date.now() }); + while (_undoStack.length > UNDO_MAX) _undoStack.shift(); + _redoStack.length = 0; + updateUndoRedoUI(); + } + + // Debounced push: collapses rapid edits to the same logical input + // (e.g., dragging a slider) into a single history entry. The first + // call within the window captures the PRE-edit state; subsequent + // calls just reset the timer. + function pushHistoryDebounced(key, label, ms = 400) { + if (_isRestoring) return; + if (!_undoDebounceTimers.has(key)) { + // First touch — push the pre-edit snapshot immediately. + pushHistory(label); + } + clearTimeout(_undoDebounceTimers.get(key)); + _undoDebounceTimers.set(key, setTimeout(() => { + _undoDebounceTimers.delete(key); + }, ms)); + } + + function undo() { + if (!_undoStack.length) return; + const current = cloneStateSnapshot(); + _redoStack.push({ label: 'redo', snapshot: current, at: Date.now() }); + const entry = _undoStack.pop(); + restoreStateSnapshot(entry.snapshot); + } + + function redo() { + if (!_redoStack.length) return; + const current = cloneStateSnapshot(); + _undoStack.push({ label: 'undo', snapshot: current, at: Date.now() }); + const entry = _redoStack.pop(); + restoreStateSnapshot(entry.snapshot); + } + + function updateUndoRedoUI() { + const u = $('undoBtn'); + const r = $('redoBtn'); + if (u) { + u.disabled = _undoStack.length === 0; + const last = _undoStack[_undoStack.length - 1]; + u.title = last ? `Undo: ${last.label} (Ctrl+Z)` : 'Nothing to undo'; + } + if (r) { + r.disabled = _redoStack.length === 0; + const last = _redoStack[_redoStack.length - 1]; + r.title = last ? `Redo: ${last.label} (Ctrl+Shift+Z)` : 'Nothing to redo'; + } + } + + function isEditableFocus() { + const el = document.activeElement; + if (!el) return false; + const tag = el.tagName; + if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true; + if (el.isContentEditable) return true; + return false; + } + function render() { // Rebuild global clip-path before any renderer so layer // wrappers can reference them via clip-path: url(#…). @@ -4692,6 +4912,7 @@ const raw = $(`url-${key}`).value.trim(); if (!raw) return; try { + pushHistory(`load ${key} image`); state.templateMode = 'cover'; state.artworkMode = 'separate'; state.slots[key].src = normalizeImageUrl(raw); @@ -4703,12 +4924,14 @@ }); $(`fit-${key}`).addEventListener('change', (event) => { + pushHistory(`change ${key} fit`); state.slots[key].fit = event.target.value; render(); }); for (const prop of ['zoom', 'x', 'y', 'rotate']) { $(`${prop}-${key}`).addEventListener('input', (event) => { + pushHistoryDebounced(`slot:${key}:${prop}`, `change ${key} ${prop}`, 400); state.slots[key][prop] = Number(event.target.value); syncSlotInputs(key); render(); @@ -4716,12 +4939,14 @@ } $(`reset-${key}`).addEventListener('click', () => { + pushHistory(`reset ${key}`); Object.assign(state.slots[key], { fit: 'cover', zoom: 1, x: 0, y: 0, rotate: 0 }); syncSlotInputs(key); render(); }); $(`clear-${key}`).addEventListener('click', () => { + pushHistory(`clear ${key} image`); state.slots[key].src = ''; $(`url-${key}`).value = ''; render(); @@ -4799,6 +5024,7 @@ const raw = $(`jewel-url-${key}`).value.trim(); if (!raw) return; try { + pushHistory(`load jewel ${key} image`); state.templateMode = 'jewel'; state.jewelInserts[key].src = normalizeImageUrl(raw); syncInputs(); @@ -4809,12 +5035,14 @@ }); $(`jewel-fit-${key}`).addEventListener('change', (event) => { + pushHistory(`change jewel ${key} fit`); state.jewelInserts[key].fit = event.target.value; render(); }); for (const prop of ['zoom', 'x', 'y', 'rotate']) { $(`jewel-${prop}-${key}`).addEventListener('input', (event) => { + pushHistoryDebounced(`jewel:${key}:${prop}`, `change jewel ${key} ${prop}`, 400); state.jewelInserts[key][prop] = Number(event.target.value); syncJewelInputs(key); render(); @@ -4822,12 +5050,14 @@ } $(`jewel-reset-${key}`).addEventListener('click', () => { + pushHistory(`reset jewel ${key}`); Object.assign(state.jewelInserts[key], { fit: 'cover', zoom: 1, x: 0, y: 0, rotate: 0 }); syncJewelInputs(key); render(); }); $(`jewel-clear-${key}`).addEventListener('click', () => { + pushHistory(`clear jewel ${key} image`); state.jewelInserts[key].src = ''; $(`jewel-url-${key}`).value = ''; render(); @@ -5365,6 +5595,7 @@ function cutSelectedLayerToClipboard() { const layer = selectedLayer(); if (!layer) { toastError('No layer selected.'); return; } + pushHistory('cut layer'); _layerClipboard.items = [snapshotLayerForClipboard(layer)]; _layerClipboard.fromLabel = modeLabelForClipboard(); state.layers = state.layers.filter(l => l.id !== layer.id); @@ -5389,6 +5620,7 @@ function pasteLayersFromClipboard() { const items = _layerClipboard.items; if (!items.length) { toastError('Clipboard is empty.'); return; } + pushHistory(`paste ${items.length} layer${items.length === 1 ? '' : 's'}`); const target = currentPasteTarget(); const bounds = targetBounds(target); let newLast = null; @@ -5480,6 +5712,7 @@ const target = targetOverride || $('layerTarget')?.value || 'page'; const bounds = targetBounds(target); applyTargetMode(target); + pushHistory('add image layer'); addLayer({ id: makeLayerId(), type: 'image', @@ -5500,6 +5733,7 @@ const target = targetOverride || $('layerTarget')?.value || 'page'; const bounds = targetBounds(target); applyTargetMode(target); + pushHistory('add text layer'); addLayer({ id: makeLayerId(), type: 'text', @@ -5526,6 +5760,7 @@ const target = targetOverride || $('layerTarget')?.value || 'page'; const bounds = targetBounds(target); applyTargetMode(target); + pushHistory('add color layer'); addLayer({ id: makeLayerId(), type: 'color', @@ -5854,6 +6089,7 @@ function updateSvgElementAttr(elementIndex, attrName, attrValue) { const layer = selectedLayer(); if (!layer || layer?.shape?.kind !== 'svg') return; + pushHistoryDebounced(`svgEl:${elementIndex}:${attrName}`, `change SVG element ${attrName}`, 400); const nextInner = setSvgPaintableAttribute(layer.shape.inner, elementIndex, attrName, attrValue); if (nextInner === layer.shape.inner) return; layer.shape = { ...layer.shape, inner: nextInner }; @@ -5917,6 +6153,7 @@ const size = Math.min(bounds.w, bounds.h) * 0.4; const w = size; const h = size; + pushHistory(`add ${SHAPE_LABELS[kind] || kind}`); addLayer({ id: makeLayerId(), type: 'shape', @@ -6388,6 +6625,7 @@ if (err) { err.textContent = 'Multi-element SVGs can\'t be used as a clip. Upload a single-path SVG instead.'; err.style.display = ''; } return; } + pushHistory('set clip path'); layer.clip = { kind: 'inline-shape', shape: clipShape }; syncLayerClipInputs(layer); render(); @@ -6398,6 +6636,7 @@ if (target.mode === 'edit') { const layer = selectedLayer(); if (layer && layer.type === 'shape') { + pushHistory('replace SVG'); layer.shape = newShape; syncShapeLayerInputs(layer); render(); @@ -6408,6 +6647,7 @@ const bounds = targetBounds(target_); applyTargetMode(target_); const size = Math.min(bounds.w, bounds.h) * 0.4; + pushHistory(parsed.kind === 'svg' ? 'add SVG import' : 'add custom path'); addLayer({ id: makeLayerId(), type: 'shape', @@ -6659,6 +6899,7 @@ text.style.fontWeight = layer.bold ? '800' : '400'; text.style.fontStyle = layer.italic ? 'italic' : 'normal'; text.addEventListener('input', (event) => { + pushHistoryDebounced(`text:${layer.id}`, 'edit text', 600); layer.html = event.currentTarget.innerHTML; localStorage.setItem('case-cover-template-maker-autosave', JSON.stringify(state)); }); @@ -7945,6 +8186,7 @@ function updateShapeFill(color) { const layer = selectedLayer(); if (!layer || layer.type !== 'shape') return; + pushHistoryDebounced('shapeFill', 'change shape fill', 400); layer.fill = { ...(layer.fill || {}), type: 'solid', color }; render(); } @@ -7952,6 +8194,7 @@ function updateShapeFillType(newType) { const layer = selectedLayer(); if (!layer || layer.type !== 'shape') return; + pushHistory('change fill type'); const prev = layer.fill || {}; const prevColor = prev.color || SHAPE_DEFAULT_FILL; const prevStops = normalizeGradientStops(prev.stops); @@ -7981,6 +8224,7 @@ function updateShapeGradientStop(index, color) { const layer = selectedLayer(); if (!layer || layer.type !== 'shape') return; + pushHistoryDebounced(`gradStop:${index}`, 'change gradient color', 400); const stops = normalizeGradientStops(layer.fill?.stops).map(s => ({ ...s })); const i = Math.max(0, Math.min(stops.length - 1, Math.round(index))); stops[i] = { ...stops[i], color }; @@ -7991,6 +8235,7 @@ function updateShapeFillAngle(angle) { const layer = selectedLayer(); if (!layer || layer.type !== 'shape') return; + pushHistoryDebounced('gradAngle', 'change gradient angle', 400); const a = Math.max(0, Math.min(360, Number(angle) || 0)); layer.fill = { ...(layer.fill || {}), angle: a }; const out = $('shapeFillAngleOut'); @@ -8001,6 +8246,7 @@ function updateShapeFillCenter(axis, value) { const layer = selectedLayer(); if (!layer || layer.type !== 'shape') return; + pushHistoryDebounced(`gradCenter:${axis}`, 'change gradient center', 400); const v = clamp01(Number(value), 0, 1); const center = { ...(layer.fill?.center || { x: 0.5, y: 0.5 }) }; center[axis] = v; @@ -8011,6 +8257,7 @@ function updateShapeFillRadius(value) { const layer = selectedLayer(); if (!layer || layer.type !== 'shape') return; + pushHistoryDebounced('gradRadius', 'change gradient radius', 400); const r = clamp01(Number(value), 0.01, 1); layer.fill = { ...(layer.fill || {}), radius: r }; render(); @@ -8019,6 +8266,7 @@ function updateShapeField(field, value) { const layer = selectedLayer(); if (!layer || layer.type !== 'shape') return; + pushHistoryDebounced(`shapeField:${field}`, `change shape ${field}`, 400); layer.shape = { ...(layer.shape || {}), [field]: value }; render(); } @@ -8026,6 +8274,7 @@ function updateShapeStrokeField(field, value) { const layer = selectedLayer(); if (!layer || layer.type !== 'shape') return; + pushHistoryDebounced(`shapeStrokeField:${field}`, `change stroke ${field}`, 300); layer.stroke = { type: 'none', color: '#000000', width: 2, dash: 'solid', ...(layer.stroke || {}), [field]: value }; render(); } @@ -8120,6 +8369,7 @@ function setLayerClipKind(newKind) { const layer = selectedLayer(); if (!layer) return; + pushHistory(`set clip: ${newKind}`); if (newKind === 'none') { delete layer.clip; } else if (newKind === 'layer-ref') { @@ -8146,6 +8396,7 @@ function setLayerClipShapeField(field, value) { const layer = selectedLayer(); if (!layer || layer.clip?.kind !== 'inline-shape') return; + pushHistoryDebounced(`clipShape:${field}`, `change clip ${field}`, 400); layer.clip = { kind: 'inline-shape', shape: { ...(layer.clip.shape || {}), [field]: value } @@ -8156,6 +8407,7 @@ function setLayerClipLayerRef(layerId) { const layer = selectedLayer(); if (!layer) return; + pushHistory('change clip layer ref'); layer.clip = { kind: 'layer-ref', layerId }; render(); } @@ -8183,6 +8435,7 @@ function updateShapeStrokeType(newType) { const layer = selectedLayer(); if (!layer || layer.type !== 'shape') return; + pushHistory('change stroke type'); const prev = layer.stroke || {}; layer.stroke = { type: newType, @@ -8280,6 +8533,7 @@ function updateSelectedLayer(prop, value) { const layer = selectedLayer(); if (!layer) return; + pushHistoryDebounced(`updateSelectedLayer:${prop}`, `change ${prop}`, 400); layer[prop] = value; render(); } @@ -8304,6 +8558,7 @@ if (!layer) return; const next = Number(value); if (!Number.isFinite(next) || next <= 0) return; + pushHistoryDebounced('layerDim', `resize ${changed}`, 400); const other = changed === 'w' ? 'h' : 'w'; const oldChanged = Number(layer[changed]) || 0; const oldOther = Number(layer[other]) || 0; @@ -8342,10 +8597,19 @@ const startY = event.clientY; const originX = layer.x; const originY = layer.y; + let moved = false; + let snapshotted = false; document.body.classList.add('dragging-layer'); el.setPointerCapture(event.pointerId); const move = (moveEvent) => { + if (!snapshotted) { + // Capture the pre-drag state on the first pointermove so a click + // without movement doesn't pollute the undo stack. + pushHistory('move layer'); + snapshotted = true; + } + moved = true; const dx = (moveEvent.clientX - startX) / sheetRect.width * (isDesignerLayer ? discTemplate.discD : sheetW()); const dy = (moveEvent.clientY - startY) / sheetRect.height * (isDesignerLayer ? discTemplate.discD : sheetH()); layer.x = originX + dx; @@ -8373,6 +8637,14 @@ el.addEventListener('pointercancel', stop); renderLayerList(); syncLayerInputs(); + // Pull the Selected-layer panel into view in the sidebar so users + // can immediately edit the layer they just picked from the canvas. + // `block: 'nearest'` is a no-op when it's already visible, so this + // doesn't jitter when you click between adjacent layers. + const controlsEl = $('layerControls'); + if (controlsEl) { + try { controlsEl.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); } catch {} + } } function readDimensionInputs() { @@ -9015,6 +9287,7 @@ } function resetDiscDesignerState() { + if (!_isRestoring) pushHistory('close disc design'); 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 = blankMeta(); @@ -9039,6 +9312,7 @@ } function resetTemplateProjectState() { + if (!_isRestoring) pushHistory('close project'); 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 } }; @@ -9070,6 +9344,7 @@ } function enterEditMode({ dTag, kind, eventId, title }) { + if (!_isRestoring) pushHistory('enter edit mode'); state.editingDTag = String(dTag || ''); state.editingKind = Number(kind) || 30078; state.editingEventId = String(eventId || ''); @@ -9078,6 +9353,7 @@ } function exitEditMode() { + if (!_isRestoring && state.editingDTag) pushHistory('exit edit mode'); state.editingDTag = ''; state.editingKind = 0; state.editingEventId = ''; @@ -9280,6 +9556,7 @@ function loadProjectFromText(text) { const parsed = JSON.parse(text); + if (!_isRestoring) pushHistory('load project'); // Loading a project file is always a fresh-start operation — never an // in-place edit of a published event. Drop any prior editing context. exitEditMode(); @@ -13261,6 +13538,8 @@ } function switchAppMode(mode) { + if (state.appMode === mode) return; + pushHistory(`switch to ${mode === 'designer' ? 'Disc Designer' : 'Template'}`); if (state.appMode === 'designer') persistCurrentDiscDesign(); swapMetaForAppMode(mode); state.appMode = mode; @@ -13390,6 +13669,8 @@ const btn = $(`mode-${mode}`); if (!btn) continue; btn.addEventListener('click', () => { + if (state.templateMode === mode) return; + pushHistory(`switch to ${mode}`); state.templateMode = mode; if (mode === 'jewel') { state.preset = 'jewel8693'; @@ -13412,6 +13693,7 @@ const caPresetEl = $('customArtPreset'); if (caPresetEl) caPresetEl.addEventListener('change', (e) => { + pushHistory('change canvas preset'); 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' }; @@ -13424,6 +13706,7 @@ render(); }); const setCustomArtSize = () => { + pushHistoryDebounced('customArt:size', 'resize canvas', 400); const w = Number($('customArtWidth')?.value) || state.customArt.width; const h = Number($('customArtHeight')?.value) || state.customArt.height; state.customArt = state.customArt || {}; @@ -13437,7 +13720,8 @@ 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) => { + if (caBg) caBg.addEventListener('change', (e) => { + pushHistory('change canvas background'); state.customArt = state.customArt || {}; state.customArt.background = e.target.value; render(); @@ -13445,40 +13729,56 @@ for (const id of ['panelW', 'trimH', 'spineW', 'bleed', 'safe']) { $(id).addEventListener('input', () => { + pushHistoryDebounced(`dim:${id}`, `change ${id}`, 400); setCustomWhenEdited(); readDimensionInputs(); }); } $('zoom').addEventListener('input', (event) => { - state.zoom = Number(event.target.value); - $('zoomOut').textContent = `${state.zoom}%`; - render(); + setManualZoom(event.target.value); + }); + + // Canvas-bottom toolbar — sticky zoom controls under the preview. + on('zoomSliderBottom', 'input', (e) => setManualZoom(e.target.value)); + on('zoomOutBtn', 'click', () => setManualZoom(state.zoom - 10)); + on('zoomInBtn', 'click', () => setManualZoom(state.zoom + 10)); + on('zoomFitBtn', 'click', resetZoomAuto); + on('zoom100Btn', 'click', () => setManualZoom(100)); + + // Recompute fit on window resize, only while auto-fit is active. + let _resizeTimer = null; + window.addEventListener('resize', () => { + if (!state.zoomAuto) return; + clearTimeout(_resizeTimer); + _resizeTimer = setTimeout(() => { if (state.zoomAuto) render(); }, 120); }); for (const id of ['showGuides', 'showBleed', 'showCrops', 'printGuides', 'printCrops']) { $(id).addEventListener('change', (event) => { + pushHistory(`toggle ${id}`); state[id] = event.target.checked; render(); }); } - $("titleField").addEventListener("input", (e)=>{state.meta.title=e.target.value; if (state.discDesign) state.discDesign.name = e.target.value; render();}); - $("imdbField").addEventListener("input", (e)=>{state.meta.imdbId=e.target.value; render();}); - const upcInput = $("upcField"); if (upcInput) upcInput.addEventListener("input", (e)=>{state.meta.upc=e.target.value; render();}); - const tvdbInput = $("tvdbField"); if (tvdbInput) tvdbInput.addEventListener("input", (e)=>{state.meta.tvdbId=e.target.value; render();}); - const mbInput = $("musicbrainzField"); if (mbInput) mbInput.addEventListener("input", (e)=>{state.meta.musicbrainzDiscId=e.target.value; render();}); - $("categoryField").addEventListener("change", (e)=>{state.meta.category=e.target.value; syncIdentifierVisibility(); render();}); + // Metadata text inputs — debounced so typing doesn't pollute history. + $("titleField").addEventListener("input", (e)=>{pushHistoryDebounced('meta:title', 'edit title', 600); state.meta.title=e.target.value; if (state.discDesign) state.discDesign.name = e.target.value; render();}); + $("imdbField").addEventListener("input", (e)=>{pushHistoryDebounced('meta:imdb', 'edit IMDb ID', 600); state.meta.imdbId=e.target.value; render();}); + const upcInput = $("upcField"); if (upcInput) upcInput.addEventListener("input", (e)=>{pushHistoryDebounced('meta:upc', 'edit UPC', 600); state.meta.upc=e.target.value; render();}); + const tvdbInput = $("tvdbField"); if (tvdbInput) tvdbInput.addEventListener("input", (e)=>{pushHistoryDebounced('meta:tvdb', 'edit TheTVDB ID', 600); state.meta.tvdbId=e.target.value; render();}); + const mbInput = $("musicbrainzField"); if (mbInput) mbInput.addEventListener("input", (e)=>{pushHistoryDebounced('meta:mb', 'edit MusicBrainz', 600); state.meta.musicbrainzDiscId=e.target.value; render();}); + $("categoryField").addEventListener("change", (e)=>{pushHistory(`change category to ${e.target.value || 'Uncategorized'}`); state.meta.category=e.target.value; syncIdentifierVisibility(); render();}); const customTagInput = $("customTagField"); - if (customTagInput) customTagInput.addEventListener("input", (e)=>{state.meta.customTag = e.target.value; render();}); - $("languageField").addEventListener("change", (e)=>{state.meta.language=e.target.value; render();}); - $("designerTitleField").addEventListener("input", (e)=>{state.meta.title=e.target.value; if (state.discDesign) state.discDesign.name = e.target.value; render();}); - $("designerImdbField").addEventListener("input", (e)=>{state.meta.imdbId=e.target.value; render();}); - const dUpcInput = $("designerUpcField"); if (dUpcInput) dUpcInput.addEventListener("input", (e)=>{state.meta.upc=e.target.value; render();}); - const dTvdbInput = $("designerTvdbField"); if (dTvdbInput) dTvdbInput.addEventListener("input", (e)=>{state.meta.tvdbId=e.target.value; render();}); - const dMbInput = $("designerMusicbrainzField"); if (dMbInput) dMbInput.addEventListener("input", (e)=>{state.meta.musicbrainzDiscId=e.target.value; render();}); - $("designerCategoryField").addEventListener("change", (e)=>{state.meta.category=e.target.value; syncIdentifierVisibility(); render();}); - $("designerLanguageField").addEventListener("change", (e)=>{state.meta.language=e.target.value; render();}); + if (customTagInput) customTagInput.addEventListener("input", (e)=>{pushHistoryDebounced('meta:customTag', 'edit custom tag', 600); state.meta.customTag = e.target.value; render();}); + $("languageField").addEventListener("change", (e)=>{pushHistory('change language'); state.meta.language=e.target.value; render();}); + $("designerTitleField").addEventListener("input", (e)=>{pushHistoryDebounced('meta:title', 'edit title', 600); state.meta.title=e.target.value; if (state.discDesign) state.discDesign.name = e.target.value; render();}); + $("designerImdbField").addEventListener("input", (e)=>{pushHistoryDebounced('meta:imdb', 'edit IMDb ID', 600); state.meta.imdbId=e.target.value; render();}); + const dUpcInput = $("designerUpcField"); if (dUpcInput) dUpcInput.addEventListener("input", (e)=>{pushHistoryDebounced('meta:upc', 'edit UPC', 600); state.meta.upc=e.target.value; render();}); + const dTvdbInput = $("designerTvdbField"); if (dTvdbInput) dTvdbInput.addEventListener("input", (e)=>{pushHistoryDebounced('meta:tvdb', 'edit TheTVDB ID', 600); state.meta.tvdbId=e.target.value; render();}); + const dMbInput = $("designerMusicbrainzField"); if (dMbInput) dMbInput.addEventListener("input", (e)=>{pushHistoryDebounced('meta:mb', 'edit MusicBrainz', 600); state.meta.musicbrainzDiscId=e.target.value; render();}); + $("designerCategoryField").addEventListener("change", (e)=>{pushHistory(`change category to ${e.target.value || 'Uncategorized'}`); state.meta.category=e.target.value; syncIdentifierVisibility(); render();}); + $("designerLanguageField").addEventListener("change", (e)=>{pushHistory('change language'); state.meta.language=e.target.value; render();}); $("imdbImportBtn").addEventListener("click", async ()=>{ const imdb = state.meta.imdbId.trim(); @@ -13787,6 +14087,7 @@ const raw = $('designerUrl').value.trim(); if (!raw) return; try { + pushHistory('load disc design image'); state.discDesign.src = normalizeImageUrl(raw); render(); } catch (error) { @@ -13795,6 +14096,7 @@ }); $('designerClearBtn').addEventListener('click', () => { + pushHistory('clear disc design image'); state.discDesign.src = ''; state.discDesign.layers = []; state.layers = state.layers.filter(l => l.target !== 'designer-disc'); @@ -13803,12 +14105,14 @@ }); $('fit-designer').addEventListener('change', (e) => { + pushHistory('change disc design fit'); state.discDesign.fit = e.target.value; render(); }); for (const prop of ['zoom', 'x', 'y', 'rotate']) { $(`${prop}-designer`).addEventListener('input', (e) => { + pushHistoryDebounced(`disc:${prop}`, `change disc ${prop}`, 400); state.discDesign[prop] = Number(e.target.value); syncDesignerInputs(); render(); @@ -13816,26 +14120,31 @@ } $('designerResetBtn').addEventListener('click', () => { + pushHistory('reset disc design transform'); Object.assign(state.discDesign, { fit: 'cover', zoom: 1, x: 0, y: 0, rotate: 0 }); syncDesignerInputs(); render(); }); $('spineTextTop').addEventListener('input', e => { + pushHistoryDebounced('spineLabel:top', 'edit top disc label', 600); state.discSpineLabels = { ...state.discSpineLabels, top: e.target.value }; render(); }); $('spineTextBottom').addEventListener('input', e => { + pushHistoryDebounced('spineLabel:bottom', 'edit bottom disc label', 600); state.discSpineLabels = { ...state.discSpineLabels, bottom: e.target.value }; render(); }); const jslEl = $('jewelSpineTextLeft'); if (jslEl) jslEl.addEventListener('input', e => { + pushHistoryDebounced('jewelSpine:left', 'edit left jewel label', 600); state.jewelSpineLabels = { ...state.jewelSpineLabels, left: e.target.value }; render(); }); const jsrEl = $('jewelSpineTextRight'); if (jsrEl) jsrEl.addEventListener('input', e => { + pushHistoryDebounced('jewelSpine:right', 'edit right jewel label', 600); state.jewelSpineLabels = { ...state.jewelSpineLabels, right: e.target.value }; render(); }); @@ -13921,6 +14230,7 @@ }); $('artworkMode').addEventListener('change', (event) => { + pushHistory(`switch artwork mode → ${event.target.value}`); state.artworkMode = event.target.value; render(); }); @@ -13929,6 +14239,7 @@ const raw = $('combinedUrl').value.trim(); if (!raw) return; try { + pushHistory('load combined wrap image'); state.templateMode = 'cover'; state.artworkMode = 'combined'; state.combined.src = normalizeImageUrl(raw); @@ -13940,17 +14251,20 @@ }); $('area-combined').addEventListener('change', (event) => { + pushHistory('change wrap span area'); state.combined.area = event.target.value; render(); }); $('fit-combined').addEventListener('change', (event) => { + pushHistory('change wrap fit'); state.combined.fit = event.target.value; render(); }); for (const prop of ['zoom', 'x', 'y', 'rotate']) { $(`${prop}-combined`).addEventListener('input', (event) => { + pushHistoryDebounced(`combined:${prop}`, `change wrap ${prop}`, 400); state.combined[prop] = Number(event.target.value); syncCombinedInputs(); render(); @@ -13958,8 +14272,7 @@ } bindRangeReset('reset-zoom', () => { - state.zoom = 60; - syncInputs(); + resetZoomAuto(); }); bindRangeReset('reset-zoom-combined', () => { state.combined.zoom = 1; syncCombinedInputs(); }); @@ -14013,6 +14326,7 @@ }); $('clearBtn').addEventListener('click', () => { + pushHistory('clear cover images'); for (const { key } of slotNames) { state.slots[key].src = ''; $(`url-${key}`).value = ''; @@ -14027,6 +14341,7 @@ }); $('clearDiscBtn').addEventListener('click', () => { + pushHistory('clear disc design'); state.discDesign.src = ''; state.discDesign.layers = []; state.layers = state.layers.filter(l => l.target !== 'designer-disc'); @@ -14037,6 +14352,7 @@ }); $('clearJewelBtn').addEventListener('click', () => { + pushHistory('clear jewel images'); for (const { key } of jewelSlotNames) { state.jewelInserts[key].src = ''; const input = $(`jewel-url-${key}`); @@ -14107,6 +14423,7 @@ textContentEl.addEventListener('input', (event) => { const layer = selectedLayer(); if (!layer || layer.type !== 'text') return; + pushHistoryDebounced(`text:${layer.id}`, 'edit text', 600); layer.html = layerPlainTextToHtml(event.target.value); render(); }); @@ -14174,6 +14491,7 @@ ? peers.find(l => (l.z || 0) > curZ) : [...peers].reverse().find(l => (l.z || 0) < curZ); if (!swap) return; + pushHistory(direction === 'forward' ? 'move layer forward' : 'move layer back'); const swapZ = swap.z || 0; swap.z = curZ; layer.z = swapZ; @@ -14185,6 +14503,7 @@ $('deleteLayer').addEventListener('click', () => { if (!state.selectedLayerId) return; + pushHistory('delete layer'); state.layers = state.layers.filter((layer) => layer.id !== state.selectedLayerId); state.selectedLayerId = ''; render(); @@ -14199,6 +14518,7 @@ if (duplicateBtn) duplicateBtn.addEventListener('click', () => { const layer = selectedLayer(); if (!layer) return; + pushHistory('duplicate layer'); const OFFSET = 0.1; const copy = { ...layer, @@ -14319,6 +14639,26 @@ }); // ESC closes whichever overlay modal is on top. + on('undoBtn', 'click', undo); + on('redoBtn', 'click', redo); + + // Global undo/redo keyboard shortcuts. Bypass when an editable + // element is focused so the browser's native text-input undo runs. + document.addEventListener('keydown', (e) => { + const k = (e.key || '').toLowerCase(); + const mod = e.ctrlKey || e.metaKey; + if (mod && k === 'z' && !isEditableFocus()) { + e.preventDefault(); + if (e.shiftKey) redo(); else undo(); + return; + } + if (mod && k === 'y' && !isEditableFocus()) { + e.preventDefault(); + redo(); + return; + } + }); + document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeActionMenus(); if (e.key !== 'Escape') return;