mirror of
https://github.com/PR0M3TH3AN/Artstr-Studio.git
synced 2026-11-02 19:50:39 +00:00
Strict layout isolation + per-mode metadata + layer clipboard
Strict layout isolation:
- isLayerForCurrentMode no longer treats target='page' or empty as a
universal "show everywhere" wildcard. Each layer now belongs to
exactly one sub-mode (cover / disc-sheet / jewel / customart) by
its target string.
- Removed "Whole page" from the layer-target dropdown so users can't
create new layers that leak across sub-modes.
- migrateLegacyLayerTargets retargets legacy page/empty layers to the
current sub-mode's primary target on load (cover→cover-trim,
disc→disc-top, jewel→jewel-front, customart→canvas). Wired into all
four mode-specific load paths and the legacy full-project path.
- Fixes the bug where case-cover artwork was rendering misaligned in
the disc-sheet preview after switching sub-modes.
Per-mode metadata isolation:
- state.savedMetaByMode parks Template-mode and Disc-Designer-mode
metadata separately. swapMetaForAppMode runs before any appMode
flip (user-driven switchAppMode, loadProjectFromText auto-switch,
importDiscDesignFromPayload's openDesigner path), so loading a
template file in disc-designer mode no longer overwrites the
designer's title / category / identifiers.
- omdbApiKey is treated as a per-user preference and stays sticky
across mode switches.
Layer clipboard:
- Session-only clipboard supports cross-sub-mode and cross-tool
paste with retargeting. Selected-layer panel gains Copy and Cut
buttons; the Layers panel grows a Copy all / Paste header row with
a status hint ("Clipboard: N layers from X").
- Paste retargets every item to the current sub-mode's primary
target (or designer-disc in Disc Designer) and clamps positions
so a layer copied from a wide cover doesn't land off-canvas when
pasted into a 4.6" disc. Each pasted layer gets a fresh id and z;
the last one becomes the selection.
- Resets on reload — not persisted to localStorage in v1.
Co-Authored-By: Claude Opus 4.7 <[email protected]>
This commit is contained in:
1 parent
c5a04423ad
commit
52dbfae945
1 file changed
+160
-10
+160
-10
@@ -1617,6 +1617,10 @@
|
||||
.publishConfirmMetaRow dt { min-width:108px; font-weight:600; color:var(--muted); margin:0; }
|
||||
.publishConfirmMetaRow dd { margin:0; flex:1; word-break:break-word; color:var(--ink); }
|
||||
|
||||
.layerClipboardRow { display:flex; gap:6px; align-items:center; flex-wrap:wrap; margin:0 0 8px; }
|
||||
.layerClipboardRow button { font-size:.78rem; padding:4px 10px; }
|
||||
.layerClipboardRow .hint { font-size:.72rem; margin-left:auto; }
|
||||
|
||||
.editingBanner { display:flex; align-items:center; justify-content:space-between; gap:12px; padding:8px 14px; background:rgba(251,191,36,.12); border-bottom:1px solid rgba(251,191,36,.4); font-size:.85rem; color:#92400e; flex-wrap:wrap; }
|
||||
body.dark .editingBanner { background:rgba(251,191,36,.1); color:#fbbf24; }
|
||||
.editingBannerText { flex:1; min-width:0; }
|
||||
@@ -2288,6 +2292,11 @@
|
||||
|
||||
<fieldset class="workflowStep template-only" id="layersFieldset">
|
||||
<legend data-step="2">Layers</legend>
|
||||
<div class="layerClipboardRow">
|
||||
<button type="button" id="layerCopyAllBtn">Copy all</button>
|
||||
<button type="button" id="layerPasteBtn" disabled>Paste</button>
|
||||
<span class="hint" id="layerClipboardStatus">Clipboard: empty</span>
|
||||
</div>
|
||||
<div class="layerList" id="layerList"></div>
|
||||
|
||||
<details id="layerControls">
|
||||
@@ -2377,6 +2386,8 @@
|
||||
<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" id="layerCopyBtn">Copy</button>
|
||||
<button type="button" id="layerCutBtn">Cut</button>
|
||||
<button type="button" class="danger" id="deleteLayer">Delete layer</button>
|
||||
</div>
|
||||
</details>
|
||||
@@ -3038,17 +3049,24 @@
|
||||
];
|
||||
|
||||
const TEMPLATE_LAYER_TARGETS = [
|
||||
{ key: 'page', label: 'Whole page' },
|
||||
{ key: 'cover-trim', label: 'Cover trim area' },
|
||||
{ key: 'back', label: 'Back cover' },
|
||||
{ key: 'spine', label: 'Spine' },
|
||||
{ key: 'front', label: 'Front cover' }
|
||||
];
|
||||
|
||||
// The "primary" target for each sub-mode — used when migrating legacy
|
||||
// page/empty-target layers and as the paste destination for the clipboard.
|
||||
function primaryTargetForMode(mode) {
|
||||
if (mode === 'disc') return 'disc-top';
|
||||
if (mode === 'jewel') return 'jewel-front';
|
||||
if (mode === 'customart') return 'canvas';
|
||||
return 'cover-trim'; // cover mode
|
||||
}
|
||||
|
||||
function isLayerForCurrentMode(layer) {
|
||||
const t = String(layer?.target || '');
|
||||
// 'page' is shared / "whole page" — render in any template mode (legacy).
|
||||
if (t === '' || t === 'page') return true;
|
||||
if (!t) return false; // strict isolation: no target = doesn't belong here
|
||||
const mode = state.templateMode;
|
||||
if (mode === 'customart') return t === 'canvas';
|
||||
if (mode === 'disc') return t.startsWith('disc-');
|
||||
@@ -3057,14 +3075,25 @@
|
||||
return true;
|
||||
}
|
||||
|
||||
// Legacy payloads (and any layer with target='page' or no target) were
|
||||
// implicitly rendered in every template sub-mode, which is what caused
|
||||
// case art to leak into the disc preview. Migrate them to the current
|
||||
// sub-mode's primary target on load so they belong to exactly one mode.
|
||||
function migrateLegacyLayerTargets(layers, mode) {
|
||||
const primary = primaryTargetForMode(mode || state.templateMode || 'cover');
|
||||
return (layers || []).map(l => {
|
||||
const t = String(l?.target || '');
|
||||
if (t === '' || t === 'page') return { ...l, target: primary };
|
||||
return l;
|
||||
});
|
||||
}
|
||||
|
||||
function templateLayerTargetsForMode() {
|
||||
if (state.templateMode === 'disc') return [
|
||||
{ key: 'page', label: 'Whole page' },
|
||||
{ key: 'disc-top', label: 'Disc 1' },
|
||||
{ key: 'disc-bottom', label: 'Disc 2' }
|
||||
];
|
||||
if (state.templateMode === 'jewel') return [
|
||||
{ key: 'page', label: 'Whole page' },
|
||||
{ key: 'jewel-front', label: 'Jewel front insert' },
|
||||
{ key: 'jewel-tray', label: 'Jewel tray insert' }
|
||||
];
|
||||
@@ -3173,6 +3202,7 @@
|
||||
darkMode: false,
|
||||
appMode: 'template',
|
||||
templateMode: 'cover',
|
||||
savedMetaByMode: {},
|
||||
editingDTag: '',
|
||||
editingKind: 0,
|
||||
editingEventId: '',
|
||||
@@ -5056,6 +5086,100 @@
|
||||
return `layer-${Date.now()}-${Math.floor(Math.random() * 10000)}`;
|
||||
}
|
||||
|
||||
// Session-only layer clipboard — copy/cut/paste layers between sub-modes
|
||||
// (cover ↔ disc ↔ jewel ↔ customart) and across the Template ↔ Disc
|
||||
// Designer boundary. Items are layer payloads with id/z stripped so paste
|
||||
// mints fresh ones.
|
||||
const _layerClipboard = { items: [], fromLabel: '' };
|
||||
|
||||
function currentPasteTarget() {
|
||||
if (state.appMode === 'designer') return 'designer-disc';
|
||||
return primaryTargetForMode(state.templateMode || 'cover');
|
||||
}
|
||||
|
||||
function modeLabelForClipboard() {
|
||||
if (state.appMode === 'designer') return 'Disc Designer';
|
||||
const m = state.templateMode || 'cover';
|
||||
if (m === 'cover') return 'Cover';
|
||||
if (m === 'disc') return 'Disc sheet';
|
||||
if (m === 'jewel') return 'Jewel';
|
||||
if (m === 'customart') return 'Custom art';
|
||||
return m;
|
||||
}
|
||||
|
||||
function updateLayerClipboardUI() {
|
||||
const status = $('layerClipboardStatus');
|
||||
const pasteBtn = $('layerPasteBtn');
|
||||
if (status) {
|
||||
status.textContent = _layerClipboard.items.length
|
||||
? `Clipboard: ${_layerClipboard.items.length} layer${_layerClipboard.items.length === 1 ? '' : 's'} from ${_layerClipboard.fromLabel}`
|
||||
: 'Clipboard: empty';
|
||||
}
|
||||
if (pasteBtn) pasteBtn.disabled = _layerClipboard.items.length === 0;
|
||||
}
|
||||
|
||||
function snapshotLayerForClipboard(layer) {
|
||||
// Strip identity / stack fields; keep visual/positional ones.
|
||||
const { id, z, _generatedDiscLayer, _staleGenerated, target, ...rest } = layer;
|
||||
return { ...rest };
|
||||
}
|
||||
|
||||
function copySelectedLayerToClipboard() {
|
||||
const layer = selectedLayer();
|
||||
if (!layer) { toastError('No layer selected.'); return; }
|
||||
_layerClipboard.items = [snapshotLayerForClipboard(layer)];
|
||||
_layerClipboard.fromLabel = modeLabelForClipboard();
|
||||
updateLayerClipboardUI();
|
||||
toastSuccess('Copied layer.');
|
||||
}
|
||||
|
||||
function cutSelectedLayerToClipboard() {
|
||||
const layer = selectedLayer();
|
||||
if (!layer) { toastError('No layer selected.'); return; }
|
||||
_layerClipboard.items = [snapshotLayerForClipboard(layer)];
|
||||
_layerClipboard.fromLabel = modeLabelForClipboard();
|
||||
state.layers = state.layers.filter(l => l.id !== layer.id);
|
||||
state.selectedLayerId = '';
|
||||
updateLayerClipboardUI();
|
||||
render();
|
||||
toastSuccess('Cut layer to clipboard.');
|
||||
}
|
||||
|
||||
function copyAllLayersToClipboard() {
|
||||
const all = state.layers
|
||||
.filter(l => state.appMode === 'designer'
|
||||
? l.target === 'designer-disc' && !l._generatedDiscLayer
|
||||
: isLayerForCurrentMode(l));
|
||||
if (!all.length) { toastError('No layers in this view to copy.'); return; }
|
||||
_layerClipboard.items = all.map(snapshotLayerForClipboard);
|
||||
_layerClipboard.fromLabel = modeLabelForClipboard();
|
||||
updateLayerClipboardUI();
|
||||
toastSuccess(`Copied ${all.length} layer${all.length === 1 ? '' : 's'}.`);
|
||||
}
|
||||
|
||||
function pasteLayersFromClipboard() {
|
||||
const items = _layerClipboard.items;
|
||||
if (!items.length) { toastError('Clipboard is empty.'); return; }
|
||||
const target = currentPasteTarget();
|
||||
const bounds = targetBounds(target);
|
||||
let newLast = null;
|
||||
for (const item of items) {
|
||||
const w = Math.max(0.1, Math.min(item.w || bounds.w * 0.7, bounds.w));
|
||||
const h = Math.max(0.1, Math.min(item.h || bounds.h * 0.7, bounds.h));
|
||||
let x = typeof item.x === 'number' ? item.x : bounds.x + (bounds.w - w) / 2;
|
||||
let y = typeof item.y === 'number' ? item.y : bounds.y + (bounds.h - h) / 2;
|
||||
// Clamp inside the target bounds so cross-sub-mode pastes don't land off-canvas.
|
||||
if (x < bounds.x || x + w > bounds.x + bounds.w) x = bounds.x + (bounds.w - w) / 2;
|
||||
if (y < bounds.y || y + h > bounds.y + bounds.h) y = bounds.y + (bounds.h - h) / 2;
|
||||
const layer = { ...item, id: makeLayerId(), target, x, y, w, h, z: nextLayerZ() };
|
||||
state.layers.push(layer);
|
||||
newLast = layer;
|
||||
}
|
||||
if (newLast) state.selectedLayerId = newLast.id;
|
||||
render();
|
||||
toastSuccess(`Pasted ${items.length} layer${items.length === 1 ? '' : 's'} into ${modeLabelForClipboard()}.`);
|
||||
}
|
||||
|
||||
function targetBounds(target) {
|
||||
const disc = (key) => {
|
||||
const slot = discTemplate.slots.find((item) => item.key === key);
|
||||
@@ -7400,7 +7524,7 @@
|
||||
const target = String(l.target || '');
|
||||
return target.startsWith('disc') || target.startsWith('jewel');
|
||||
}),
|
||||
...(Array.isArray(parsed.layers) ? parsed.layers : [])
|
||||
...migrateLegacyLayerTargets(parsed.layers, 'cover')
|
||||
],
|
||||
selectedLayerId: ''
|
||||
});
|
||||
@@ -7420,7 +7544,7 @@
|
||||
...(spineLabels ? { discSpineLabels: { ...state.discSpineLabels, ...spineLabels } } : {}),
|
||||
layers: [
|
||||
...state.layers.filter(l => !String(l.target || '').startsWith('disc')),
|
||||
...(Array.isArray(parsed.layers) ? parsed.layers : [])
|
||||
...migrateLegacyLayerTargets(parsed.layers, 'disc')
|
||||
],
|
||||
selectedLayerId: ''
|
||||
});
|
||||
@@ -7442,7 +7566,7 @@
|
||||
...(jewelSpineLabels ? { jewelSpineLabels: { ...state.jewelSpineLabels, ...jewelSpineLabels } } : {}),
|
||||
layers: [
|
||||
...state.layers.filter(l => !String(l.target || '').startsWith('jewel')),
|
||||
...(Array.isArray(parsed.layers) ? parsed.layers : [])
|
||||
...migrateLegacyLayerTargets(parsed.layers, 'jewel')
|
||||
],
|
||||
selectedLayerId: ''
|
||||
});
|
||||
@@ -7464,7 +7588,7 @@
|
||||
// replace the canvas-target layers with the forked design's layers.
|
||||
layers: [
|
||||
...state.layers.filter(l => l.target !== 'canvas'),
|
||||
...(Array.isArray(parsed.layers) ? parsed.layers : [])
|
||||
...migrateLegacyLayerTargets(parsed.layers, 'customart')
|
||||
],
|
||||
selectedLayerId: ''
|
||||
});
|
||||
@@ -7504,6 +7628,8 @@
|
||||
layers: Array.isArray(parsed.layers) ? parsed.layers : []
|
||||
}
|
||||
: parsed;
|
||||
const sourceTemplateMode = source.templateMode || state.templateMode || 'cover';
|
||||
const migratedLayers = migrateLegacyLayerTargets(source.layers, sourceTemplateMode);
|
||||
const next = {
|
||||
...state,
|
||||
...source,
|
||||
@@ -7514,7 +7640,7 @@
|
||||
// backward compat handled in normalizeDiscState
|
||||
jewelInserts: { ...state.jewelInserts, ...(source.jewelInserts || {}) },
|
||||
community: { ...state.community, ...(source.community || {}) },
|
||||
layers: Array.isArray(source.layers) ? source.layers : [],
|
||||
layers: migratedLayers,
|
||||
selectedLayerId: ''
|
||||
};
|
||||
Object.assign(state, next);
|
||||
@@ -11223,6 +11349,7 @@
|
||||
state._discImportTargetSlot = null;
|
||||
|
||||
if (!isSlotImport && openDesigner) {
|
||||
swapMetaForAppMode('designer');
|
||||
state.appMode = 'designer';
|
||||
if (state.templateMode !== 'disc') {
|
||||
state._prevTemplateMode = state.templateMode;
|
||||
@@ -11306,8 +11433,25 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Save the metadata currently in state.meta into savedMetaByMode for the
|
||||
// old appMode, and load the saved metadata for the new appMode (or a fresh
|
||||
// blank if we've never been there). omdbApiKey is treated as a per-user
|
||||
// preference and stays sticky across mode switches.
|
||||
function swapMetaForAppMode(newMode) {
|
||||
if (!state.savedMetaByMode) state.savedMetaByMode = {};
|
||||
const oldMode = state.appMode;
|
||||
if (oldMode === newMode) return;
|
||||
const apiKey = state.meta?.omdbApiKey || '';
|
||||
state.savedMetaByMode[oldMode] = { ...state.meta };
|
||||
const next = state.savedMetaByMode[newMode];
|
||||
state.meta = next
|
||||
? { ...next, omdbApiKey: apiKey || next.omdbApiKey || '' }
|
||||
: { ...blankMeta(), omdbApiKey: apiKey };
|
||||
}
|
||||
|
||||
function switchAppMode(mode) {
|
||||
if (state.appMode === 'designer') persistCurrentDiscDesign();
|
||||
swapMetaForAppMode(mode);
|
||||
state.appMode = mode;
|
||||
if (mode === 'designer') {
|
||||
if (state.templateMode !== 'disc') {
|
||||
@@ -12220,6 +12364,11 @@
|
||||
render();
|
||||
});
|
||||
|
||||
on('layerCopyAllBtn', 'click', copyAllLayersToClipboard);
|
||||
on('layerPasteBtn', 'click', pasteLayersFromClipboard);
|
||||
on('layerCopyBtn', 'click', copySelectedLayerToClipboard);
|
||||
on('layerCutBtn', 'click', cutSelectedLayerToClipboard);
|
||||
|
||||
const duplicateBtn = $('layerDuplicate');
|
||||
if (duplicateBtn) duplicateBtn.addEventListener('click', () => {
|
||||
const layer = selectedLayer();
|
||||
@@ -12274,6 +12423,7 @@
|
||||
normalizeDiscState();
|
||||
normalizeJewelState();
|
||||
populateCategorySelects();
|
||||
updateLayerClipboardUI();
|
||||
if (!localStorage.getItem('casewrap-welcomed')) {
|
||||
$('welcomeModal').classList.add('open');
|
||||
setTimeout(() => loadWelcomeTab('New'), 0);
|
||||
|
||||
Reference in new issue
Block a user